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:
Daniel 2026-09-12 20:43:05 +02:00
parent 4782e2f1a4
commit 572bf6877c
6 changed files with 798 additions and 0 deletions

View 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; }
}

View 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>
)
}

View 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')
})
})

View file

@ -104,3 +104,19 @@
}
.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%; }
}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import OverlayEditor from '../components/OverlayEditor'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import MediaTile from '../components/MediaTile'
@ -33,6 +34,9 @@ export default function MediaPage() {
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
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 [showLibraryForm, setShowLibraryForm] = useState(false)
const fileInput = useRef(null)
@ -63,6 +67,7 @@ export default function MediaPage() {
setEditing(image.id)
setDraft({
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(', '),
})
}
@ -72,6 +77,7 @@ export default function MediaPage() {
try {
await api.patch(`/media/${image.id}`, {
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),
tags: draft.tags.split(',').map(t => t.trim()).filter(Boolean),
})
@ -80,6 +86,17 @@ export default function MediaPage() {
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) => {
setBusy(true); setError('')
try {
@ -261,6 +278,30 @@ export default function MediaPage() {
<label>Tags<input value={draft.tags} placeholder="comma, separated"
aria-label={`Tags for image ${image.id}`}
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 className="media-modal-foot">
@ -271,6 +312,12 @@ export default function MediaPage() {
</div>
)
})()}
{marking && (
<OverlayEditor src={marking.path} overlay={marking.overlay}
onSave={overlay => saveOverlay(marking, overlay)}
onClose={() => setMarking(null)} />
)}
</div>
)
}

View file

@ -63,6 +63,9 @@ describe('image bank', () => {
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/media/11', {
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'],
}))
})