feat: mark regions on an image, and cite where it came from
The drawing tool for the overlay contract: freehand, rectangle, ellipse and arrow, in five colours and three weights, over the image at full screen. Every coordinate is normalised to the unit square, so one drawing is right in a 256px thumbnail and on a projector; strokes are drawn in screen pixels rather than viewBox units, because a unit square stretched over a 3:2 film would otherwise make horizontal strokes half again as heavy as vertical ones. Pointer events throughout, so a finger traces an edge as well as a mouse does. It hangs off the media library's edit dialog, beside two new fields: the citation and a link to check it. The drawing tool was written by a Fable subagent against docs/image-overlays.md, in three files of its own; the wiring, the schema and the reader's half are mine. Its judgement calls I have kept: Cancel asks before discarding a traced edge (a stray tap on a phone is the same harm as Escape), undo is a history stack rather than "remove the last shape", and freehand points closer than 0.0025 apart are thinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
4782e2f1a4
commit
572bf6877c
6 changed files with 798 additions and 0 deletions
123
frontend/src/components/OverlayEditor.css
Normal file
123
frontend/src/components/OverlayEditor.css
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/* The editor is dark whatever the page theme is. It exists to show radiographs
|
||||||
|
and photographs of skin, and a white or cream frame around a grey film pulls
|
||||||
|
the eye and shifts how the greys read. The page's theme variables stop at
|
||||||
|
the door; the colours in here are literals on purpose. */
|
||||||
|
|
||||||
|
.ovl {
|
||||||
|
position: fixed; inset: 0; z-index: 1100;
|
||||||
|
display: grid; grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
background: #0f1216; color: #e6e9ee;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.ovl:focus { outline: none; }
|
||||||
|
|
||||||
|
.ovl button {
|
||||||
|
min-height: 40px; min-width: 40px; padding: 0 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
font: inherit; color: inherit; cursor: pointer;
|
||||||
|
background: #1a1f26; border: 1px solid #343b47; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ovl button:hover { border-color: #4a5364; }
|
||||||
|
.ovl button:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.ovl button:focus-visible { outline: 2px solid #5eead4; outline-offset: 2px; }
|
||||||
|
.ovl button[aria-pressed="true"] { border-color: #5eead4; background: #12302d; color: #ffffff; }
|
||||||
|
|
||||||
|
.ovl-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #262c35;
|
||||||
|
}
|
||||||
|
.ovl-bar h2 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||||
|
.ovl-actions { display: flex; gap: 8px; }
|
||||||
|
.ovl-save { background: #5eead4; color: #062b26; border-color: transparent; font-weight: 600; }
|
||||||
|
.ovl-save:hover { background: #7ff0dd; border-color: transparent; }
|
||||||
|
|
||||||
|
.ovl-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; min-height: 0; }
|
||||||
|
|
||||||
|
.ovl-stage {
|
||||||
|
position: relative; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 12px; min-width: 0; min-height: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Kept invisible until measured: for one frame before the image reports its
|
||||||
|
size the frame has no dimensions of its own, and a full-size image flashing
|
||||||
|
past before it snaps to fit is worse than a short blank. */
|
||||||
|
.ovl-frame {
|
||||||
|
position: relative; max-width: 100%; max-height: 100%;
|
||||||
|
opacity: 0; transition: opacity 120ms ease-out;
|
||||||
|
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
.ovl-frame.is-ready { opacity: 1; }
|
||||||
|
.ovl-frame img { display: block; width: 100%; height: 100%; max-width: 100%; max-height: 100%; }
|
||||||
|
|
||||||
|
/* touch-action: none is what lets a finger draw instead of scrolling; the
|
||||||
|
pointer handlers cannot stop a scroll once the browser has started one. */
|
||||||
|
.ovl-surface {
|
||||||
|
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||||
|
touch-action: none; cursor: crosshair; overflow: visible;
|
||||||
|
}
|
||||||
|
.ovl-failed { color: #9aa3b2; }
|
||||||
|
|
||||||
|
.ovl-panel {
|
||||||
|
display: flex; flex-direction: column; gap: 12px; padding: 12px;
|
||||||
|
border-left: 1px solid #262c35; overflow-y: auto; min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ovl-tools { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.ovl-tool { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
.ovl-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.ovl-swatches, .ovl-widths { display: flex; gap: 6px; }
|
||||||
|
.ovl-swatch { padding: 0; }
|
||||||
|
.ovl-swatch i { display: block; width: 20px; height: 20px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); }
|
||||||
|
.ovl-width { padding: 0; }
|
||||||
|
.ovl-width i { display: block; width: 22px; border-radius: 3px; background: currentColor; }
|
||||||
|
|
||||||
|
.ovl-list-toggle { display: none; }
|
||||||
|
|
||||||
|
.ovl-empty { margin: 0; color: #9aa3b2; line-height: 1.45; }
|
||||||
|
.ovl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ovl-list li {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 4px 4px 4px 10px; border: 1px solid #262c35; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ovl-list li.is-active { border-color: #5eead4; }
|
||||||
|
.ovl-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.ovl-kind { flex: none; min-width: 5.5em; }
|
||||||
|
.ovl-label {
|
||||||
|
flex: 1 1 60px; min-width: 0; min-height: 40px; padding: 0 8px;
|
||||||
|
font: inherit; color: inherit; background: #0f1216;
|
||||||
|
border: 1px solid #343b47; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.ovl-label:focus { outline: 2px solid #5eead4; outline-offset: -1px; }
|
||||||
|
.ovl-remove { flex: none; padding: 0; }
|
||||||
|
.ovl-remove:hover { color: #fca5a5; border-color: #fca5a5; }
|
||||||
|
|
||||||
|
.ovl-ask-backdrop {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 16px; background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.ovl-ask {
|
||||||
|
width: min(360px, 100%); padding: 16px;
|
||||||
|
background: #1a1f26; border: 1px solid #343b47; border-radius: 12px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.ovl-ask p { margin: 0 0 12px; font-weight: 600; }
|
||||||
|
.ovl-ask-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.ovl-discard { color: #fca5a5; }
|
||||||
|
|
||||||
|
/* On a phone the panel moves under the image and gives up most of its height:
|
||||||
|
the tools stay in one scrollable row, and the list of shapes is behind a
|
||||||
|
toggle so that four rectangles do not push the film off the screen. */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.ovl-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
|
.ovl-panel { border-left: 0; border-top: 1px solid #262c35; max-height: 45dvh; padding: 8px; gap: 8px; }
|
||||||
|
.ovl-tools { flex-wrap: nowrap; overflow-x: auto; }
|
||||||
|
.ovl-tool { flex: none; }
|
||||||
|
.ovl-tool-name { display: none; }
|
||||||
|
.ovl-list-toggle { display: inline-flex; justify-content: space-between; }
|
||||||
|
.ovl-list-toggle::after { content: '▾'; }
|
||||||
|
.ovl-panel.is-open .ovl-list-toggle::after { content: '▴'; }
|
||||||
|
.ovl-list-wrap { display: none; }
|
||||||
|
.ovl-panel.is-open .ovl-list-wrap { display: block; }
|
||||||
|
.ovl-kind { min-width: 0; }
|
||||||
|
}
|
||||||
416
frontend/src/components/OverlayEditor.jsx
Normal file
416
frontend/src/components/OverlayEditor.jsx
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { uploadUrl } from '../utils/uploads'
|
||||||
|
import './OverlayEditor.css'
|
||||||
|
|
||||||
|
// The marks are stored as vectors, not burnt into a second copy of the image,
|
||||||
|
// because a learner has to be able to switch them off and look first. The
|
||||||
|
// format is docs/image-overlays.md; this editor produces it and nothing else.
|
||||||
|
|
||||||
|
// Teal first: it is the one hue that stays visible against every grey on a
|
||||||
|
// radiograph. Amber is the contract's own second example. The rest are for
|
||||||
|
// telling several marks apart on one image, not for meaning.
|
||||||
|
export const COLOURS = [
|
||||||
|
{ hex: '#5eead4', name: 'Teal' },
|
||||||
|
{ hex: '#f0a53d', name: 'Amber' },
|
||||||
|
{ hex: '#f87171', name: 'Red' },
|
||||||
|
{ hex: '#60a5fa', name: 'Blue' },
|
||||||
|
{ hex: '#ffffff', name: 'White' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Fractions of the image width, so a "medium" line carries the same relative
|
||||||
|
// weight in a 256px thumbnail and on a projector.
|
||||||
|
export const WIDTHS = [
|
||||||
|
{ value: 0.003, name: 'Thin' },
|
||||||
|
{ value: 0.006, name: 'Medium' },
|
||||||
|
{ value: 0.012, name: 'Thick' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ kind: 'path', name: 'Freehand' },
|
||||||
|
{ kind: 'rect', name: 'Rectangle' },
|
||||||
|
{ kind: 'ellipse', name: 'Ellipse' },
|
||||||
|
{ kind: 'arrow', name: 'Arrow' },
|
||||||
|
]
|
||||||
|
const KIND_NAME = Object.fromEntries(TOOLS.map((t) => [t.kind, t.name]))
|
||||||
|
|
||||||
|
// Four decimal places is a fifth of a pixel on the largest image the server
|
||||||
|
// keeps; anything finer only makes the JSON longer.
|
||||||
|
const round = (n) => Math.round(n * 10000) / 10000
|
||||||
|
const clamp01 = (n) => Math.min(1, Math.max(0, n))
|
||||||
|
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1])
|
||||||
|
|
||||||
|
// Hand jitter at rest is well under this; a genuine stroke moves further
|
||||||
|
// between events even on a slow phone.
|
||||||
|
const MIN_STEP = 0.0025
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
const nextId = () => ++seq
|
||||||
|
|
||||||
|
// Where the pointer is as a fraction of the surface, measured fresh on every
|
||||||
|
// event rather than cached, so a window resized mid-session or a phone turned
|
||||||
|
// sideways cannot leave the stored numbers pointing at the wrong place.
|
||||||
|
function normalise(svg, e) {
|
||||||
|
const r = svg.getBoundingClientRect()
|
||||||
|
if (!r.width || !r.height) return null
|
||||||
|
return [round(clamp01((e.clientX - r.left) / r.width)), round(clamp01((e.clientY - r.top) / r.height))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function extend(d, p) {
|
||||||
|
if (d.kind === 'path') {
|
||||||
|
if (dist(p, d.points[d.points.length - 1]) < MIN_STEP) return d
|
||||||
|
return { ...d, points: [...d.points, p] }
|
||||||
|
}
|
||||||
|
return { ...d, last: p }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tap is not a shape. Each kind has its own idea of "too small to mean
|
||||||
|
// anything", and dropping those here is what keeps an accidental touch on a
|
||||||
|
// phone from leaving an invisible mark in the list.
|
||||||
|
function finish(d) {
|
||||||
|
const base = { kind: d.kind, color: d.color, width: d.width }
|
||||||
|
const [sx, sy] = d.start
|
||||||
|
const [lx, ly] = d.last
|
||||||
|
switch (d.kind) {
|
||||||
|
case 'path':
|
||||||
|
return d.points.length >= 2 ? { ...base, points: d.points } : null
|
||||||
|
case 'arrow':
|
||||||
|
return dist(d.start, d.last) >= 0.01 ? { ...base, points: [d.start, d.last] } : null
|
||||||
|
case 'rect': {
|
||||||
|
const w = round(Math.abs(lx - sx))
|
||||||
|
const h = round(Math.abs(ly - sy))
|
||||||
|
return w >= 0.005 && h >= 0.005 ? { ...base, x: round(Math.min(sx, lx)), y: round(Math.min(sy, ly)), w, h } : null
|
||||||
|
}
|
||||||
|
case 'ellipse': {
|
||||||
|
const rx = round(Math.abs(lx - sx) / 2)
|
||||||
|
const ry = round(Math.abs(ly - sy) / 2)
|
||||||
|
return rx >= 0.0025 && ry >= 0.0025 ? { ...base, cx: round((sx + lx) / 2), cy: round((sy + ly) / 2), rx, ry } : null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the fields the contract names leave this component. Anything else that
|
||||||
|
// was on a stored shape is dropped on the next save rather than carried along.
|
||||||
|
function serialise(s) {
|
||||||
|
const out = { kind: s.kind }
|
||||||
|
if (s.kind === 'rect') Object.assign(out, { x: s.x, y: s.y, w: s.w, h: s.h })
|
||||||
|
else if (s.kind === 'ellipse') Object.assign(out, { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry })
|
||||||
|
else out.points = s.points
|
||||||
|
out.color = s.color
|
||||||
|
out.width = s.width
|
||||||
|
const label = (s.label || '').trim()
|
||||||
|
if (label) out.label = label
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromOverlay(overlay) {
|
||||||
|
return (overlay?.shapes || [])
|
||||||
|
.filter((s) => s && KIND_NAME[s.kind])
|
||||||
|
.map((s) => ({ ...s, id: nextId(), color: s.color || COLOURS[0].hex, width: s.width || WIDTHS[1].value, label: s.label || '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The head is worked out in pixels and mapped back, because the unit-square
|
||||||
|
// viewBox is stretched to the image's aspect ratio and an angle chosen in
|
||||||
|
// normalised space would come out skewed on anything that is not square.
|
||||||
|
function arrowHead([a, b], box, strokePx) {
|
||||||
|
const W = box.w || 1
|
||||||
|
const H = box.h || 1
|
||||||
|
const ax = a[0] * W, ay = a[1] * H, bx = b[0] * W, by = b[1] * H
|
||||||
|
const angle = Math.atan2(by - ay, bx - ax)
|
||||||
|
const len = Math.max(10, strokePx * 4)
|
||||||
|
const wing = (spread) => [(bx - len * Math.cos(angle + spread)) / W, (by - len * Math.sin(angle + spread)) / H]
|
||||||
|
return [wing(Math.PI / 7), b, wing(-Math.PI / 7)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPoints = (pts) => pts.map((p) => p.join(',')).join(' ')
|
||||||
|
|
||||||
|
function Shape({ shape, box, halo }) {
|
||||||
|
// The stroke is drawn in screen pixels (non-scaling-stroke) rather than in
|
||||||
|
// viewBox units: a unit-square viewBox stretched over a 3:2 image would
|
||||||
|
// otherwise make horizontal strokes half again as heavy as vertical ones.
|
||||||
|
const px = box.w ? shape.width * box.w : 2
|
||||||
|
const passes = halo
|
||||||
|
? [{ stroke: '#ffffff', strokeWidth: px + 6, opacity: 0.55 }, { stroke: shape.color, strokeWidth: px }]
|
||||||
|
: [{ stroke: shape.color, strokeWidth: px }]
|
||||||
|
return passes.map((pass, i) => {
|
||||||
|
const p = { fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', ...pass }
|
||||||
|
switch (shape.kind) {
|
||||||
|
case 'path':
|
||||||
|
return <polyline key={i} {...p} points={toPoints(shape.points)} />
|
||||||
|
case 'rect':
|
||||||
|
return <rect key={i} {...p} x={shape.x} y={shape.y} width={shape.w} height={shape.h} />
|
||||||
|
case 'ellipse':
|
||||||
|
return <ellipse key={i} {...p} cx={shape.cx} cy={shape.cy} rx={shape.rx} ry={shape.ry} />
|
||||||
|
case 'arrow':
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<polyline {...p} points={toPoints(shape.points)} />
|
||||||
|
<polyline {...p} points={toPoints(arrowHead(shape.points, box, pass.strokeWidth))} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolIcon({ kind }) {
|
||||||
|
const d = {
|
||||||
|
path: <path d="M3 17c3-9 6 3 9-4s5 1 9-7" />,
|
||||||
|
rect: <rect x="4" y="6" width="16" height="12" rx="1" />,
|
||||||
|
ellipse: <ellipse cx="12" cy="12" rx="8" ry="5.5" />,
|
||||||
|
arrow: <path d="M5 19 19 5M10 5h9v9" />,
|
||||||
|
}[kind]
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{d}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverlayEditor({ src, overlay, onSave, onClose }) {
|
||||||
|
// Read once: while the editor is open the shapes belong to it, and a parent
|
||||||
|
// re-render with the same overlay must not throw away work in progress.
|
||||||
|
const [initial] = useState(() => fromOverlay(overlay))
|
||||||
|
const [shapes, setShapes] = useState(initial)
|
||||||
|
const [past, setPast] = useState([])
|
||||||
|
const [tool, setTool] = useState('path')
|
||||||
|
const [color, setColor] = useState(COLOURS[0].hex)
|
||||||
|
const [width, setWidth] = useState(WIDTHS[1].value)
|
||||||
|
const [draft, setDraft] = useState(null)
|
||||||
|
const [box, setBox] = useState({ w: 0, h: 0 })
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
const [listOpen, setListOpen] = useState(false)
|
||||||
|
const [asking, setAsking] = useState(false)
|
||||||
|
|
||||||
|
const rootRef = useRef(null)
|
||||||
|
const stageRef = useRef(null)
|
||||||
|
const imgRef = useRef(null)
|
||||||
|
const svgRef = useRef(null)
|
||||||
|
|
||||||
|
const dirty = JSON.stringify(shapes.map(serialise)) !== JSON.stringify(initial.map(serialise))
|
||||||
|
|
||||||
|
// The frame is given explicit pixel dimensions rather than left to CSS
|
||||||
|
// because the SVG has to sit over the image to the pixel, and an <img>
|
||||||
|
// constrained by max-height inside a shrink-wrapped box does not give its
|
||||||
|
// wrapper the same size in every browser.
|
||||||
|
const fit = useCallback(() => {
|
||||||
|
const stage = stageRef.current
|
||||||
|
const img = imgRef.current
|
||||||
|
if (!stage || !img || !img.naturalWidth) return
|
||||||
|
const cs = getComputedStyle(stage)
|
||||||
|
const pad = (v) => parseFloat(v) || 0
|
||||||
|
const sw = stage.clientWidth - pad(cs.paddingLeft) - pad(cs.paddingRight)
|
||||||
|
const sh = stage.clientHeight - pad(cs.paddingTop) - pad(cs.paddingBottom)
|
||||||
|
if (sw <= 0 || sh <= 0) return
|
||||||
|
const s = Math.min(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||||
|
setBox({ w: Math.floor(img.naturalWidth * s), h: Math.floor(img.naturalHeight * s) })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rootRef.current?.focus()
|
||||||
|
if (imgRef.current?.complete) fit()
|
||||||
|
const stage = stageRef.current
|
||||||
|
let ro
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && stage) {
|
||||||
|
ro = new ResizeObserver(fit)
|
||||||
|
ro.observe(stage)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', fit)
|
||||||
|
return () => {
|
||||||
|
ro?.disconnect()
|
||||||
|
window.removeEventListener('resize', fit)
|
||||||
|
}
|
||||||
|
}, [fit])
|
||||||
|
|
||||||
|
// A finger that misses the surface would otherwise scroll the page under a
|
||||||
|
// full-screen editor.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => { document.body.style.overflow = prev }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
if (asking) { setAsking(false); return }
|
||||||
|
if (dirty) setAsking(true)
|
||||||
|
else onClose()
|
||||||
|
}, [asking, dirty, onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key !== 'Escape') return
|
||||||
|
e.preventDefault()
|
||||||
|
requestClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [requestClose])
|
||||||
|
|
||||||
|
const commit = (next) => {
|
||||||
|
setPast((p) => [...p, shapes])
|
||||||
|
setShapes(next)
|
||||||
|
}
|
||||||
|
const undo = () => {
|
||||||
|
if (!past.length) return
|
||||||
|
setShapes(past[past.length - 1])
|
||||||
|
setPast(past.slice(0, -1))
|
||||||
|
}
|
||||||
|
const remove = (id) => commit(shapes.filter((s) => s.id !== id))
|
||||||
|
// Labels stay out of the undo history: undoing a drawing one keystroke at a
|
||||||
|
// time is not what anyone reaching for Undo wants.
|
||||||
|
const relabel = (id, label) => setShapes(shapes.map((s) => (s.id === id ? { ...s, label } : s)))
|
||||||
|
|
||||||
|
// Pointer events rather than mouse or touch: one code path serves a mouse,
|
||||||
|
// a finger and a stylus, and capturing the pointer keeps a stroke alive when
|
||||||
|
// a fast hand runs off the edge of the image.
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
if (draft) return
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||||
|
const p = normalise(svgRef.current, e)
|
||||||
|
if (!p) return
|
||||||
|
e.preventDefault()
|
||||||
|
try { e.currentTarget.setPointerCapture(e.pointerId) } catch { /* not every environment implements capture */ }
|
||||||
|
setDraft({ kind: tool, color, width, start: p, last: p, points: [p], pointerId: e.pointerId })
|
||||||
|
}
|
||||||
|
const onPointerMove = (e) => {
|
||||||
|
if (!draft || e.pointerId !== draft.pointerId) return
|
||||||
|
const p = normalise(svgRef.current, e)
|
||||||
|
if (p) setDraft((d) => (d ? extend(d, p) : d))
|
||||||
|
}
|
||||||
|
const onPointerUp = (e) => {
|
||||||
|
if (!draft || e.pointerId !== draft.pointerId) return
|
||||||
|
const p = normalise(svgRef.current, e)
|
||||||
|
const done = finish(p ? extend(draft, p) : draft)
|
||||||
|
setDraft(null)
|
||||||
|
if (done) commit([...shapes, { ...done, id: nextId(), label: '' }])
|
||||||
|
}
|
||||||
|
const onPointerCancel = () => setDraft(null)
|
||||||
|
|
||||||
|
const save = () => onSave(shapes.length ? { shapes: shapes.map(serialise) } : null)
|
||||||
|
|
||||||
|
const live = draft ? finish(draft) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ovl" role="dialog" aria-modal="true" aria-label="Mark regions on the image" ref={rootRef} tabIndex={-1}>
|
||||||
|
<header className="ovl-bar">
|
||||||
|
<h2>Mark regions</h2>
|
||||||
|
<div className="ovl-actions">
|
||||||
|
<button type="button" onClick={undo} disabled={!past.length}>Undo</button>
|
||||||
|
<button type="button" onClick={requestClose}>Cancel</button>
|
||||||
|
<button type="button" className="ovl-save" onClick={save}>Save</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ovl-body">
|
||||||
|
<div className="ovl-stage" ref={stageRef}>
|
||||||
|
{failed ? (
|
||||||
|
<p className="ovl-failed">The image could not be loaded.</p>
|
||||||
|
) : (
|
||||||
|
<div className={`ovl-frame${box.w ? ' is-ready' : ''}`} style={box.w ? { width: box.w, height: box.h } : undefined}>
|
||||||
|
<img ref={imgRef} src={uploadUrl(src)} alt="Image to mark up" draggable={false} onLoad={fit} onError={() => setFailed(true)} />
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="ovl-surface"
|
||||||
|
viewBox="0 0 1 1"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-label="Drawing surface"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerCancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{shapes.map((s) => <Shape key={s.id} shape={s} box={box} halo={s.id === activeId} />)}
|
||||||
|
{live && <Shape shape={live} box={box} />}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className={`ovl-panel${listOpen ? ' is-open' : ''}`}>
|
||||||
|
<div className="ovl-tools" role="group" aria-label="Drawing tool">
|
||||||
|
{TOOLS.map((t) => (
|
||||||
|
<button key={t.kind} type="button" className="ovl-tool" aria-pressed={tool === t.kind} aria-label={t.name} onClick={() => setTool(t.kind)}>
|
||||||
|
<ToolIcon kind={t.kind} />
|
||||||
|
<span className="ovl-tool-name">{t.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ovl-row">
|
||||||
|
<div className="ovl-swatches" role="group" aria-label="Colour">
|
||||||
|
{COLOURS.map((c) => (
|
||||||
|
<button key={c.hex} type="button" className="ovl-swatch" aria-pressed={color === c.hex} aria-label={c.name} onClick={() => setColor(c.hex)}>
|
||||||
|
<i style={{ background: c.hex }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ovl-widths" role="group" aria-label="Stroke width">
|
||||||
|
{WIDTHS.map((w, i) => (
|
||||||
|
<button key={w.value} type="button" className="ovl-width" aria-pressed={width === w.value} aria-label={w.name} onClick={() => setWidth(w.value)}>
|
||||||
|
<i style={{ height: 2 + i * 2.5 }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="ovl-list-toggle" aria-expanded={listOpen} onClick={() => setListOpen((o) => !o)}>
|
||||||
|
Shapes ({shapes.length})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ovl-list-wrap">
|
||||||
|
{shapes.length === 0 ? (
|
||||||
|
<p className="ovl-empty">Nothing drawn yet. Choose a tool and drag on the image.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="ovl-list" aria-label="Shapes">
|
||||||
|
{shapes.map((s, i) => {
|
||||||
|
const name = KIND_NAME[s.kind]
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className={s.id === activeId ? 'is-active' : ''}
|
||||||
|
onPointerEnter={() => setActiveId(s.id)}
|
||||||
|
onPointerLeave={() => setActiveId(null)}
|
||||||
|
onFocus={() => setActiveId(s.id)}
|
||||||
|
onBlur={() => setActiveId(null)}
|
||||||
|
>
|
||||||
|
<span className="ovl-dot" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
<span className="ovl-kind">{name}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ovl-label"
|
||||||
|
value={s.label}
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
aria-label={`Label for ${lower} ${i + 1}`}
|
||||||
|
onChange={(e) => relabel(s.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="ovl-remove" aria-label={`Remove ${lower} ${i + 1}`} onClick={() => remove(s.id)}>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asking && (
|
||||||
|
<div className="ovl-ask-backdrop">
|
||||||
|
<div className="ovl-ask" role="alertdialog" aria-labelledby="ovl-ask-title">
|
||||||
|
<p id="ovl-ask-title">Discard your changes?</p>
|
||||||
|
<div className="ovl-ask-actions">
|
||||||
|
<button type="button" className="ovl-discard" onClick={onClose}>Discard</button>
|
||||||
|
<button type="button" autoFocus onClick={() => setAsking(false)}>Keep editing</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
frontend/src/components/OverlayEditor.test.jsx
Normal file
193
frontend/src/components/OverlayEditor.test.jsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import OverlayEditor from './OverlayEditor'
|
||||||
|
|
||||||
|
// jsdom lays nothing out, so the surface is told where it is. The gesture
|
||||||
|
// helpers speak in fractions and turn them into clientX/Y against this box,
|
||||||
|
// which is the same arithmetic the component has to undo.
|
||||||
|
const BOX = { left: 100, top: 50, width: 400, height: 200 }
|
||||||
|
const rectOf = (b) => ({ ...b, right: b.left + b.width, bottom: b.top + b.height, x: b.left, y: b.top, toJSON() {} })
|
||||||
|
|
||||||
|
function setup(props = {}, box = BOX) {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<OverlayEditor src="figs/knee.png" overlay={null} onSave={onSave} onClose={onClose} {...props} />)
|
||||||
|
const surface = screen.getByLabelText('Drawing surface')
|
||||||
|
surface.getBoundingClientRect = () => rectOf(box)
|
||||||
|
const at = ([fx, fy]) => ({ clientX: box.left + box.width * fx, clientY: box.top + box.height * fy, pointerId: 1, button: 0 })
|
||||||
|
const drag = (pts) => {
|
||||||
|
fireEvent.pointerDown(surface, at(pts[0]))
|
||||||
|
pts.slice(1).forEach((p) => fireEvent.pointerMove(surface, at(p)))
|
||||||
|
fireEvent.pointerUp(surface, at(pts[pts.length - 1]))
|
||||||
|
}
|
||||||
|
return { onSave, onClose, surface, drag }
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
const pick = (name) => userEvent.click(screen.getByRole('button', { name }))
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('OverlayEditor', () => {
|
||||||
|
it('stores a freehand path as points normalised to the image, not pixels', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
drag([[0.1, 0.2], [0.4, 0.5], [0.5, 0.7]])
|
||||||
|
await save()
|
||||||
|
expect(onSave).toHaveBeenCalledWith({
|
||||||
|
shapes: [{ kind: 'path', points: [[0.1, 0.2], [0.4, 0.5], [0.5, 0.7]], color: '#5eead4', width: 0.006 }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives the same numbers wherever and however large the image is on screen', async () => {
|
||||||
|
const a = setup()
|
||||||
|
a.drag([[0.25, 0.25], [0.75, 0.5]])
|
||||||
|
await save()
|
||||||
|
const first = a.onSave.mock.calls[0][0]
|
||||||
|
a.onClose.mockClear()
|
||||||
|
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
const b = setup({}, { left: 0, top: 300, width: 1000, height: 800 })
|
||||||
|
b.drag([[0.25, 0.25], [0.75, 0.5]])
|
||||||
|
await save()
|
||||||
|
expect(b.onSave.mock.calls[0][0]).toEqual(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts on freehand and switches tools with a pressed state', async () => {
|
||||||
|
setup()
|
||||||
|
expect(screen.getByRole('button', { name: 'Freehand' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
await pick('Rectangle')
|
||||||
|
expect(screen.getByRole('button', { name: 'Rectangle' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(screen.getByRole('button', { name: 'Freehand' })).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws each kind in the shape the contract names, whichever way the drag goes', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
await pick('Rectangle')
|
||||||
|
drag([[0.5, 0.6], [0.2, 0.3]])
|
||||||
|
await pick('Ellipse')
|
||||||
|
drag([[0.4, 0.4], [0.6, 0.5]])
|
||||||
|
await pick('Arrow')
|
||||||
|
drag([[0.2, 0.2], [0.4, 0.35]])
|
||||||
|
await save()
|
||||||
|
expect(onSave.mock.calls[0][0].shapes).toEqual([
|
||||||
|
{ kind: 'rect', x: 0.2, y: 0.3, w: 0.3, h: 0.3, color: '#5eead4', width: 0.006 },
|
||||||
|
{ kind: 'ellipse', cx: 0.5, cy: 0.45, rx: 0.1, ry: 0.05, color: '#5eead4', width: 0.006 },
|
||||||
|
{ kind: 'arrow', points: [[0.2, 0.2], [0.4, 0.35]], color: '#5eead4', width: 0.006 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not turn a tap into a shape', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
drag([[0.3, 0.3]])
|
||||||
|
await pick('Rectangle')
|
||||||
|
drag([[0.3, 0.3], [0.301, 0.3]])
|
||||||
|
expect(screen.getByText(/Nothing drawn yet/)).toBeInTheDocument()
|
||||||
|
await save()
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the chosen colour and stroke width on the shape', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
await pick('Amber')
|
||||||
|
await pick('Thick')
|
||||||
|
drag([[0.1, 0.1], [0.3, 0.3]])
|
||||||
|
await save()
|
||||||
|
expect(onSave.mock.calls[0][0].shapes[0]).toMatchObject({ color: '#f0a53d', width: 0.012 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists what has been drawn and removes a shape from the list', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
drag([[0.1, 0.1], [0.3, 0.3]])
|
||||||
|
await pick('Rectangle')
|
||||||
|
drag([[0.5, 0.5], [0.7, 0.8]])
|
||||||
|
const list = screen.getByRole('list', { name: 'Shapes' })
|
||||||
|
expect(within(list).getAllByRole('listitem')).toHaveLength(2)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Remove freehand 1' }))
|
||||||
|
expect(within(list).getAllByRole('listitem')).toHaveLength(1)
|
||||||
|
await save()
|
||||||
|
expect(onSave.mock.calls[0][0].shapes).toEqual([expect.objectContaining({ kind: 'rect' })])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves null once every shape is gone, so the column goes back to empty', async () => {
|
||||||
|
const overlay = { shapes: [{ kind: 'rect', x: 0.1, y: 0.1, w: 0.2, h: 0.2, color: '#5eead4' }] }
|
||||||
|
const { onSave } = setup({ overlay })
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Remove rectangle 1' }))
|
||||||
|
await save()
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undoes the last shape', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
|
||||||
|
drag([[0.1, 0.1], [0.3, 0.3]])
|
||||||
|
drag([[0.5, 0.5], [0.7, 0.7]])
|
||||||
|
await pick('Undo')
|
||||||
|
await save()
|
||||||
|
expect(onSave.mock.calls[0][0].shapes).toEqual([expect.objectContaining({ points: [[0.1, 0.1], [0.3, 0.3]] })])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches a label to a shape and leaves the field out when it is blank', async () => {
|
||||||
|
const { onSave, drag } = setup()
|
||||||
|
drag([[0.1, 0.1], [0.3, 0.3]])
|
||||||
|
drag([[0.5, 0.5], [0.7, 0.7]])
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: 'Label for freehand 1' }), 'Dense metaphyseal bands')
|
||||||
|
await save()
|
||||||
|
const [first, second] = onSave.mock.calls[0][0].shapes
|
||||||
|
expect(first.label).toBe('Dense metaphyseal bands')
|
||||||
|
expect(second).not.toHaveProperty('label')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens an existing overlay for editing and adds to it', async () => {
|
||||||
|
const overlay = { shapes: [{ kind: 'arrow', points: [[0.2, 0.2], [0.4, 0.35]], color: '#f0a53d', width: 0.003, label: 'Fracture line' }] }
|
||||||
|
const { onSave, drag } = setup({ overlay })
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Label for arrow 1' })).toHaveValue('Fracture line')
|
||||||
|
drag([[0.6, 0.6], [0.8, 0.8]])
|
||||||
|
await save()
|
||||||
|
expect(onSave.mock.calls[0][0].shapes).toEqual([overlay.shapes[0], expect.objectContaining({ kind: 'path' })])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel closes without saving', async () => {
|
||||||
|
const { onSave, onClose } = setup()
|
||||||
|
await pick('Cancel')
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escape closes at once when nothing has changed', async () => {
|
||||||
|
const { onClose } = setup()
|
||||||
|
await userEvent.keyboard('{Escape}')
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks inline before throwing away unsaved work, never with a browser popup', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const { onSave, onClose, drag } = setup()
|
||||||
|
drag([[0.1, 0.1], [0.3, 0.3]])
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Escape}')
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
const ask = screen.getByRole('alertdialog', { name: 'Discard your changes?' })
|
||||||
|
await userEvent.click(within(ask).getByRole('button', { name: 'Keep editing' }))
|
||||||
|
expect(screen.queryByRole('alertdialog')).toBeNull()
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||||
|
|
||||||
|
await pick('Cancel')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Discard' }))
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
expect(confirm).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves the image through the uploads helper', () => {
|
||||||
|
setup()
|
||||||
|
expect(screen.getByRole('img', { name: 'Image to mark up' })).toHaveAttribute('src', '/uploads/figs/knee.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws on a unit-square surface stretched to the image, so stored numbers are SVG coordinates', () => {
|
||||||
|
const { surface } = setup()
|
||||||
|
expect(surface).toHaveAttribute('viewBox', '0 0 1 1')
|
||||||
|
expect(surface).toHaveAttribute('preserveAspectRatio', 'none')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -104,3 +104,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-used { font-size: 0.72rem; color: var(--primary); font-weight: 600; }
|
.media-used { font-size: 0.72rem; color: var(--primary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* The marks an educator has put on an image, in the edit dialog. A row rather
|
||||||
|
than another field: the marking happens somewhere else, full screen, and
|
||||||
|
this is the door to it plus what is behind that door already. */
|
||||||
|
.media-overlay-row {
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
padding: 11px 12px; border: 1px solid var(--border); border-radius: 9px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.media-overlay-row span { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||||
|
.media-overlay-row strong { font-size: 0.86rem; }
|
||||||
|
.media-overlay-row small { font-size: 0.78rem; line-height: 1.45; color: var(--text-muted); }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.media-overlay-row .btn { width: 100%; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import OverlayEditor from '../components/OverlayEditor'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import MediaTile from '../components/MediaTile'
|
import MediaTile from '../components/MediaTile'
|
||||||
|
|
@ -33,6 +34,9 @@ export default function MediaPage() {
|
||||||
const [draft, setDraft] = useState(null)
|
const [draft, setDraft] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
|
//: The image whose regions are being marked, or null. Full screen, so it is
|
||||||
|
//: its own state rather than a panel inside the edit dialog.
|
||||||
|
const [marking, setMarking] = useState(null)
|
||||||
const [newLibrary, setNewLibrary] = useState('')
|
const [newLibrary, setNewLibrary] = useState('')
|
||||||
const [showLibraryForm, setShowLibraryForm] = useState(false)
|
const [showLibraryForm, setShowLibraryForm] = useState(false)
|
||||||
const fileInput = useRef(null)
|
const fileInput = useRef(null)
|
||||||
|
|
@ -63,6 +67,7 @@ export default function MediaPage() {
|
||||||
setEditing(image.id)
|
setEditing(image.id)
|
||||||
setDraft({
|
setDraft({
|
||||||
title: image.title || '', caption: image.caption || '', alt_text: image.alt_text || '',
|
title: image.title || '', caption: image.caption || '', alt_text: image.alt_text || '',
|
||||||
|
source: image.source || '', source_url: image.source_url || '',
|
||||||
library_id: image.library_id ?? '', tags: (image.tags || []).join(', '),
|
library_id: image.library_id ?? '', tags: (image.tags || []).join(', '),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +77,7 @@ export default function MediaPage() {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/media/${image.id}`, {
|
await api.patch(`/media/${image.id}`, {
|
||||||
title: draft.title || null, caption: draft.caption || null, alt_text: draft.alt_text || null,
|
title: draft.title || null, caption: draft.caption || null, alt_text: draft.alt_text || null,
|
||||||
|
source: draft.source || null, source_url: draft.source_url || null,
|
||||||
library_id: draft.library_id === '' ? null : Number(draft.library_id),
|
library_id: draft.library_id === '' ? null : Number(draft.library_id),
|
||||||
tags: draft.tags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: draft.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
})
|
})
|
||||||
|
|
@ -80,6 +86,17 @@ export default function MediaPage() {
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveOverlay = async (image, overlay) => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
await api.patch(`/media/${image.id}`, { overlay })
|
||||||
|
setMarking(null)
|
||||||
|
setNotice(overlay ? 'Marks saved.' : 'Marks removed.')
|
||||||
|
load()
|
||||||
|
} catch (err) { setError(apiError(err, 'Could not save those marks')) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
const remove = async (image, force = false) => {
|
const remove = async (image, force = false) => {
|
||||||
setBusy(true); setError('')
|
setBusy(true); setError('')
|
||||||
try {
|
try {
|
||||||
|
|
@ -261,6 +278,30 @@ export default function MediaPage() {
|
||||||
<label>Tags<input value={draft.tags} placeholder="comma, separated"
|
<label>Tags<input value={draft.tags} placeholder="comma, separated"
|
||||||
aria-label={`Tags for image ${image.id}`}
|
aria-label={`Tags for image ${image.id}`}
|
||||||
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
|
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
|
||||||
|
{/* The citation belongs to the file, not to each caption that
|
||||||
|
uses it: the same figure in three articles is cited the
|
||||||
|
same way in all three. */}
|
||||||
|
<label>Source<textarea rows={2} value={draft.source}
|
||||||
|
placeholder="Figure 2, in: … by …, licensed under CC BY 4.0"
|
||||||
|
aria-label={`Source for image ${image.id}`}
|
||||||
|
onChange={e => setDraft(d => ({ ...d, source: e.target.value }))} /></label>
|
||||||
|
<label>Source link<input value={draft.source_url} placeholder="https://"
|
||||||
|
aria-label={`Source link for image ${image.id}`}
|
||||||
|
onChange={e => setDraft(d => ({ ...d, source_url: e.target.value }))} /></label>
|
||||||
|
<div className="media-overlay-row">
|
||||||
|
<span>
|
||||||
|
<strong>Overlay</strong>
|
||||||
|
<small>
|
||||||
|
{(image.overlay?.shapes || []).length
|
||||||
|
? `${image.overlay.shapes.length} region${image.overlay.shapes.length === 1 ? '' : 's'} marked — a learner turns these on themselves.`
|
||||||
|
: 'Mark the part of the image a learner should be looking at.'}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setMarking(image)}>
|
||||||
|
{(image.overlay?.shapes || []).length ? 'Edit marks' : 'Mark regions'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-modal-foot">
|
<div className="media-modal-foot">
|
||||||
|
|
@ -271,6 +312,12 @@ export default function MediaPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{marking && (
|
||||||
|
<OverlayEditor src={marking.path} overlay={marking.overlay}
|
||||||
|
onSave={overlay => saveOverlay(marking, overlay)}
|
||||||
|
onClose={() => setMarking(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ describe('image bank', () => {
|
||||||
|
|
||||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/media/11', {
|
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/media/11', {
|
||||||
title: 'Chest film', caption: 'Right lower lobe', alt_text: 'CXR',
|
title: 'Chest film', caption: 'Right lower lobe', alt_text: 'CXR',
|
||||||
|
// Null rather than absent: an empty citation clears the one that was
|
||||||
|
// there, which is the only way to correct a wrong attribution.
|
||||||
|
source: null, source_url: null,
|
||||||
library_id: 1, tags: ['pneumonia', 'consolidation'],
|
library_id: 1, tags: ['pneumonia', 'consolidation'],
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue