From 572bf6877c492909b66b03cf1f07efc3c236b3c1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 20:43:05 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/OverlayEditor.css | 123 ++++++ frontend/src/components/OverlayEditor.jsx | 416 ++++++++++++++++++ .../src/components/OverlayEditor.test.jsx | 193 ++++++++ frontend/src/pages/MediaPage.css | 16 + frontend/src/pages/MediaPage.jsx | 47 ++ frontend/src/pages/MediaPage.test.jsx | 3 + 6 files changed, 798 insertions(+) create mode 100644 frontend/src/components/OverlayEditor.css create mode 100644 frontend/src/components/OverlayEditor.jsx create mode 100644 frontend/src/components/OverlayEditor.test.jsx diff --git a/frontend/src/components/OverlayEditor.css b/frontend/src/components/OverlayEditor.css new file mode 100644 index 0000000..b8296ac --- /dev/null +++ b/frontend/src/components/OverlayEditor.css @@ -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; } +} diff --git a/frontend/src/components/OverlayEditor.jsx b/frontend/src/components/OverlayEditor.jsx new file mode 100644 index 0000000..1765d8e --- /dev/null +++ b/frontend/src/components/OverlayEditor.jsx @@ -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 + case 'rect': + return + case 'ellipse': + return + case 'arrow': + return ( + + + + + ) + default: + return null + } + }) +} + +function ToolIcon({ kind }) { + const d = { + path: , + rect: , + ellipse: , + arrow: , + }[kind] + return ( + + ) +} + +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 + // 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 ( +
+
+

Mark regions

+
+ + + +
+
+ +
+
+ {failed ? ( +

The image could not be loaded.

+ ) : ( +
+ Image to mark up setFailed(true)} /> + e.preventDefault()} + > + {shapes.map((s) => )} + {live && } + +
+ )} +
+ + +
+ + {asking && ( +
+
+

Discard your changes?

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/OverlayEditor.test.jsx b/frontend/src/components/OverlayEditor.test.jsx new file mode 100644 index 0000000..ff893b6 --- /dev/null +++ b/frontend/src/components/OverlayEditor.test.jsx @@ -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() + 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') + }) +}) diff --git a/frontend/src/pages/MediaPage.css b/frontend/src/pages/MediaPage.css index c564079..e2ece59 100644 --- a/frontend/src/pages/MediaPage.css +++ b/frontend/src/pages/MediaPage.css @@ -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%; } +} diff --git a/frontend/src/pages/MediaPage.jsx b/frontend/src/pages/MediaPage.jsx index e15cd98..a5a25f3 100644 --- a/frontend/src/pages/MediaPage.jsx +++ b/frontend/src/pages/MediaPage.jsx @@ -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() { + {/* 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. */} +