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; }
|
||||
|
||||
/* 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 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue