From 20b5d0ba4f9787a8afcdb4ab7140e5e8dcc3baa7 Mon Sep 17 00:00:00 2001 From: mayokunl <153923029+mayokunl@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:36:43 -0500 Subject: [PATCH 1/3] Add and edit annotations in the Scene Editor (#426, #427-#438) Editors can now add labels, lines, pointer lines, arrows, and highlight regions to a scene, then select them to move, reshape, style, reorder, or delete them. Every change edits the working copy and is stored with the next Save, like image edits (#451). - Text labels (#427-#430): click to place, edit the text in the toolbar or by double-clicking, multi-line text, move by dragging or arrow keys, resize from the corner handle or the size field (8-200px). An empty label is not applied; the last text is kept. - Lines and arrows (#431, #433): drag to draw; each end has a handle. - Pointer lines (#432): drawn from a selected label to a feature and linked by labelId. Moving the label moves the line's label end, resizing re-anchors it on the label's edge, and deleting the label deletes its pointer lines. Imported legacy labels and their pointer lines behave the same way. - Highlight regions (#434-#437): click each corner, then finish with the button, Enter, double-click, or the first corner. Corners and the whole shape can be dragged. Color and fill opacity are editable; opacity is capped at 0.8 so the bone stays visible. - Layer order (#438): send to back, backward, forward, or to front. Annotations always draw above images, so image order is unchanged. All tools share the scene coordinate system. Edits are made on the annotation as drawn and mapped back into its stored frame, so annotations imported with a bone keep following their image when it is moved, resized, flipped, or rotated. Stored formats are preserved (x1/y1/x2/y2 vs points, [x, y] vs {x, y}). No server changes: the API already stores annotations as opaque objects. Also: a new mouse press now clears the flag that skips the click after a drag, so a drag whose click never arrives can no longer swallow the user's next click. Tests: sceneAnnotations.test.js (geometry and storing linked edits) and sceneAnnotationEditor.test.js (each sub-issue end to end, including save and reopen). Co-Authored-By: Claude Opus 5.5 --- templates/boneset.html | 44 +- templates/js/sceneAnnotations.js | 163 +++++ templates/js/sceneCanvas.js | 259 ++++++- templates/js/scenes.js | 689 +++++++++++++++++- templates/style.css | 93 +++ templates/tests/sceneAnnotationEditor.test.js | 665 +++++++++++++++++ templates/tests/sceneAnnotations.test.js | 211 ++++++ 7 files changed, 2102 insertions(+), 22 deletions(-) create mode 100644 templates/js/sceneAnnotations.js create mode 100644 templates/tests/sceneAnnotationEditor.test.js create mode 100644 templates/tests/sceneAnnotations.test.js diff --git a/templates/boneset.html b/templates/boneset.html index 3367c5e..40a117d 100644 --- a/templates/boneset.html +++ b/templates/boneset.html @@ -269,12 +269,54 @@

+ + + +

This scene is blank.

-

Images and annotations added to this scene will appear here. Use Import bone images to start from an existing bone, or import an image from your computer.

+

Images and annotations added to this scene will appear here. Use Import bone images to start from an existing bone, import an image from your computer, or choose a tool under Add annotation.

diff --git a/templates/js/sceneAnnotations.js b/templates/js/sceneAnnotations.js new file mode 100644 index 0000000..5ddaeb0 --- /dev/null +++ b/templates/js/sceneAnnotations.js @@ -0,0 +1,163 @@ +// Annotation editing for the Scene Editor (Issue #426: #427-#438). +// +// Every function here works on an annotation's *displayed* geometry - scene +// pixels, as sceneCanvas.js draws it (see displayedAnnotation) - and returns a +// new object without changing the one it was given. The editor stores the +// result with storeAnnotationGeometry, which maps it back into the +// annotation's own coordinates if it is linked to an image. All annotation +// tools therefore share the scene's one coordinate system. + +import { annotationPoints, textBox } from "./sceneCanvas.js"; + +export const MIN_FONT_SIZE = 8; +export const MAX_FONT_SIZE = 200; +export const MIN_STROKE_WIDTH = 1; +export const MAX_STROKE_WIDTH = 20; +// The most opaque a highlight can be, so the bone and any labels under it stay visible. +export const MIN_HIGHLIGHT_OPACITY = 0.1; +export const MAX_HIGHLIGHT_OPACITY = 0.8; + +export const DEFAULT_TEXT_COLOR = "#003366"; +export const DEFAULT_LINE_COLOR = "#003366"; +export const DEFAULT_HIGHLIGHT_COLOR = "#ffd700"; +export const DEFAULT_HIGHLIGHT_OPACITY = 0.35; + +const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + +/** The same annotation with its points replaced, in the format it already uses. */ +function withPoints(annotation, points) { + const hasEndpoints = ["x1", "y1", "x2", "y2"].every((key) => typeof annotation[key] === "number"); + if (hasEndpoints && points.length === 2) { + const [[x1, y1], [x2, y2]] = points; + return { ...annotation, x1, y1, x2, y2 }; + } + return { ...annotation, points }; +} + +/** The annotation moved by (dx, dy). */ +export function translateAnnotation(annotation, dx, dy) { + if (annotation.type === "text") return { ...annotation, x: annotation.x + dx, y: annotation.y + dy }; + const points = annotationPoints(annotation); + if (!points) return annotation; + return withPoints(annotation, points.map(([x, y]) => [x + dx, y + dy])); +} + +/** The annotation with one of its points (a line endpoint or polygon corner) moved by (dx, dy). */ +export function moveAnnotationPoint(annotation, index, dx, dy) { + const points = annotationPoints(annotation); + if (!points || index < 0 || index >= points.length) return annotation; + return withPoints(annotation, points.map(([x, y], i) => (i === index ? [x + dx, y + dy] : [x, y]))); +} + +/** A text label at a new font size, keeping its top-left corner where it is. */ +export function resizeText(annotation, fontSize) { + return { ...annotation, fontSize: clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE) }; +} + +export function clampStrokeWidth(width) { + return clamp(width, MIN_STROKE_WIDTH, MAX_STROKE_WIDTH); +} + +export function clampHighlightOpacity(opacity) { + return clamp(opacity, MIN_HIGHLIGHT_OPACITY, MAX_HIGHLIGHT_OPACITY); +} + +/** + * Where a pointer line should meet a label: the point on the label's box + * closest to `toward`. From inside the box, the middle of the nearest edge. + * @param {{ x: number, y: number, width: number, height: number }} box + * @param {[number, number]} toward + * @returns {[number, number]} + */ +export function anchorOnBox(box, toward) { + const [tx, ty] = toward; + const right = box.x + box.width; + const bottom = box.y + box.height; + const inside = tx > box.x && tx < right && ty > box.y && ty < bottom; + if (!inside) return [clamp(tx, box.x, right), clamp(ty, box.y, bottom)]; + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + const edges = [ + { distance: tx - box.x, point: [box.x, centerY] }, + { distance: right - tx, point: [right, centerY] }, + { distance: ty - box.y, point: [centerX, box.y] }, + { distance: bottom - ty, point: [centerX, bottom] }, + ]; + return edges.reduce((best, edge) => (edge.distance < best.distance ? edge : best)).point; +} + +/** + * Whether `line` is a pointer line for `label` (Issue #432): one created for it + * (`labelId`), or one imported with it from the same legacy label and image. + */ +export function isPointerLineFor(line, label) { + if (!line || !label || (line.type !== "line" && line.type !== "arrow")) return false; + if (typeof line.labelId === "string") return line.labelId === label.id; + const lineSource = line.source; + const labelSource = label.source; + return Boolean(lineSource && labelSource) && + lineSource.type === "legacy-pointer-line" && + labelSource.type === "legacy-label" && + lineSource.boneId === labelSource.boneId && + lineSource.annotationId === labelSource.annotationId && + line.sourceImageId === label.sourceImageId; +} + +/** + * Which end of a displayed pointer line sits at its label: the first point of + * a line created for the label, otherwise whichever end is nearer the label. + * @returns {number} The point index. + */ +export function labelEndIndex(line, labelBox) { + const points = annotationPoints(line); + if (!points) return -1; + const last = points.length - 1; + if (typeof line.labelId === "string") return 0; + const centerX = labelBox.x + labelBox.width / 2; + const centerY = labelBox.y + labelBox.height / 2; + const distance = ([x, y]) => Math.hypot(x - centerX, y - centerY); + return distance(points[0]) <= distance(points[last]) ? 0 : last; +} + +/** + * A displayed pointer line with its label end moved back onto the label's + * edge, after the label changed size or text. + */ +export function reanchorPointerLine(line, label) { + const points = annotationPoints(line); + if (!points || points.length < 2) return line; + const box = textBox(label); + const end = labelEndIndex(line, box); + const other = end === 0 ? points[1] : points[points.length - 2]; + const [x, y] = anchorOnBox(box, other); + return moveAnnotationPoint(line, end, x - points[end][0], y - points[end][1]); +} + +/** + * A color an `` can show: the value if it is #rrggbb + * (or #rgb, expanded), otherwise the fallback. + */ +export function toColorInputValue(value, fallback) { + if (typeof value !== "string") return fallback; + const long = value.match(/^#([0-9a-f]{6})$/i); + if (long) return `#${long[1].toLowerCase()}`; + const short = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); + if (short) return `#${short.slice(1).map((c) => c + c).join("").toLowerCase()}`; + return fallback; +} + +/** A short name for an annotation, for the toolbar and announcements. */ +export function describeAnnotation(annotation) { + switch (annotation && annotation.type) { + case "text": + return "label"; + case "line": + return typeof annotation.labelId === "string" ? "pointer line" : "line"; + case "arrow": + return typeof annotation.labelId === "string" ? "pointer arrow" : "arrow"; + case "polygon": + return "highlight"; + default: + return "annotation"; + } +} diff --git a/templates/js/sceneCanvas.js b/templates/js/sceneCanvas.js index c266ebd..0cecd8a 100644 --- a/templates/js/sceneCanvas.js +++ b/templates/js/sceneCanvas.js @@ -5,10 +5,11 @@ // Coordinates are scene pixels. // image: { src, x, y, width, height, rotation?, flipX?, flipY?, opacity? } // text annotation: { type: "text", text, x, y, fontSize?, color?, fontWeight? } +// (`text` may hold several lines separated by "\n") // line / arrow: { type: "line" | "arrow", x1, y1, x2, y2 } or { points: [[x, y], ...] }, // plus stroke?, strokeWidth? // polygon: { type: "polygon", points: [[x, y], ...] or [{ x, y }, ...] }, -// plus fill?, stroke?, strokeWidth?, opacity? +// plus fill?, fillOpacity?, stroke?, strokeWidth?, opacity? // Objects that don't match are skipped and reported as unsupported. // // Linked annotations (PR #493 review): an annotation imported with a bone's @@ -37,6 +38,14 @@ const SAFE_IMAGE_SRC = /^(\/|\.\/|https?:\/\/|data:image\/|blob:)/i; // Order matches renderImage's own `corners` array: [TL, TR, BR, BL]. const RESIZE_HANDLE_CORNERS = ["nw", "ne", "se", "sw"]; const RESIZE_HANDLE_SIZE = 10; +const POINT_HANDLE_RADIUS = 6; +// A region traced from a legacy outline can have hundreds of points; past this +// many, it can still be moved as a whole but its points get no handles. +const MAX_POINT_HANDLES = 40; +const DEFAULT_FONT_SIZE = 18; +const TEXT_LINE_HEIGHT = 1.2; +// Thin lines are hard to click, so each line also gets a wider invisible stroke. +const LINE_HIT_WIDTH = 12; let markerCount = 0; @@ -219,7 +228,33 @@ function imageTransform(image, frame) { const flipped = [image.flipX ? 2 * cx - x : x, image.flipY ? 2 * cy - y : y]; return rotation ? rotatePoints([flipped], rotation, cx, cy)[0] : flipped; }; - return { map: (point) => toScreen(toBox(point)), toScreen, scale: (sx + sy) / 2 }; + // The exact inverse, for storing an edit made on the drawn annotation. + const fromScreen = (point) => { + const [x, y] = rotation ? rotatePoints([point], -rotation, cx, cy)[0] : point; + const unflipped = [image.flipX ? 2 * cx - x : x, image.flipY ? 2 * cy - y : y]; + return [frame.x + (unflipped[0] - image.x) / sx, frame.y + (unflipped[1] - image.y) / sy]; + }; + return { map: (point) => toScreen(toBox(point)), unmap: fromScreen, toScreen, scale: (sx + sy) / 2 }; +} + +/** + * The box a text annotation is drawn in. Width is estimated from the longest + * line (the same 0.6em-per-character estimate legacyAnnotations.js uses); + * a single line is exactly `fontSize` tall, as before multi-line labels. + * @param {{ text: string, x: number, y: number, fontSize?: number }} annotation + */ +export function textBox(annotation) { + const fontSize = isNumber(annotation.fontSize) ? annotation.fontSize : DEFAULT_FONT_SIZE; + const lines = annotation.text.split("\n"); + const longest = Math.max(...lines.map((line) => line.length)); + return { + x: annotation.x, + y: annotation.y, + width: longest * fontSize * 0.6, + height: fontSize + (lines.length - 1) * fontSize * TEXT_LINE_HEIGHT, + fontSize, + lines, + }; } /** A copy of a linked annotation with its coordinates mapped onto its image. */ @@ -228,12 +263,14 @@ function mapLinkedAnnotation(annotation, transform) { switch (annotation.type) { case "text": { if (typeof annotation.text !== "string" || !isNumber(annotation.x) || !isNumber(annotation.y)) return annotation; - const fontSize = isNumber(annotation.fontSize) ? annotation.fontSize : 18; - const width = annotation.text.length * fontSize * 0.6; - const [centerX, centerY] = transform.map([annotation.x + width / 2, annotation.y + fontSize / 2]); - const newFontSize = fontSize * transform.scale; - const newWidth = annotation.text.length * newFontSize * 0.6; - return { ...annotation, x: centerX - newWidth / 2, y: centerY - newFontSize / 2, fontSize: newFontSize }; + const box = textBox(annotation); + const [centerX, centerY] = transform.map([box.x + box.width / 2, box.y + box.height / 2]); + return { + ...annotation, + x: centerX - (box.width * transform.scale) / 2, + y: centerY - (box.height * transform.scale) / 2, + fontSize: box.fontSize * transform.scale, + }; } case "line": case "arrow": { @@ -252,6 +289,73 @@ function mapLinkedAnnotation(annotation, transform) { } } +/** + * The points that shape a line, arrow, or polygon, as [x, y] pairs, or null + * for text and for objects without usable points. + */ +export function annotationPoints(annotation) { + if (!annotation) return null; + if (annotation.type === "line" || annotation.type === "arrow") return linePoints(annotation); + if (annotation.type === "polygon") return toPoints(annotation.points); + return null; +} + +/** The transform for an annotation linked to an image still in the scene, else null. */ +function linkedTransform(annotation, scene) { + if (!annotation || annotation.sourceImageId === undefined || !isValidBox(annotation.sourceImageFrame)) return null; + const image = (scene.images || []).find((candidate) => candidate && candidate.id === annotation.sourceImageId); + return image && isValidBox(image) ? imageTransform(image, annotation.sourceImageFrame) : null; +} + +/** + * An annotation as it is drawn, in scene pixels: a linked annotation is mapped + * onto its image's current box; any other annotation is returned as-is. + * Editing tools work on this and store the result with storeAnnotationGeometry. + */ +export function displayedAnnotation(annotation, scene) { + const transform = linkedTransform(annotation, scene); + return transform ? mapLinkedAnnotation(annotation, transform) : annotation; +} + +const round2 = (value) => Math.round(value * 100) / 100; + +/** + * Writes the geometry of `displayed` (an edited copy of displayedAnnotation's + * result) back into `annotation`, mapped into its own coordinates if it is + * linked to an image, so the stored frame never changes. Keeps the + * annotation's own format: x1/y1/x2/y2 or points, and [x, y] or { x, y } points. + * @param {object} annotation - The stored annotation; changed in place. + * @param {object} displayed + * @param {{ images: object[] }} scene + */ +export function storeAnnotationGeometry(annotation, displayed, scene) { + const transform = linkedTransform(annotation, scene); + const scale = transform ? transform.scale : 1; + const toStored = (point) => (transform ? transform.unmap(point) : point).map(round2); + + if (annotation.type === "text") { + const box = textBox(displayed); + const fontSize = box.fontSize / scale; + const [centerX, centerY] = toStored([box.x + box.width / 2, box.y + box.height / 2]); + annotation.fontSize = round2(fontSize); + annotation.x = round2(centerX - box.width / scale / 2); + annotation.y = round2(centerY - box.height / scale / 2); + return; + } + + const points = annotationPoints(displayed); + if (!points) return; + const stored = points.map(toStored); + const hasEndpoints = [annotation.x1, annotation.y1, annotation.x2, annotation.y2].every(isNumber); + if (hasEndpoints && stored.length === 2) { + [[annotation.x1, annotation.y1], [annotation.x2, annotation.y2]] = stored; + } else { + const asObjects = Array.isArray(annotation.points) && annotation.points.length > 0 && !Array.isArray(annotation.points[0]); + annotation.points = asObjects ? stored.map(([x, y]) => ({ x, y })) : stored; + } + if (isNumber(displayed.strokeWidth)) annotation.strokeWidth = round2(displayed.strokeWidth / scale); +} + function clipsToCrop(annotation) { return Boolean(annotation.source) && annotation.source.type === "legacy-colored-region"; } @@ -270,19 +374,30 @@ function visibleCropPolygon(image, transform) { function renderText(annotation) { if (typeof annotation.text !== "string" || !isNumber(annotation.x) || !isNumber(annotation.y)) return null; - const fontSize = isNumber(annotation.fontSize) ? annotation.fontSize : 18; + const box = textBox(annotation); const node = el("text", { x: annotation.x, y: annotation.y, - "font-size": fontSize, + "font-size": box.fontSize, "font-weight": annotation.fontWeight, fill: annotation.color || DEFAULT_STROKE, "dominant-baseline": "hanging", class: "scene-text", }); - node.textContent = annotation.text; - const approxWidth = annotation.text.length * fontSize * 0.6; - return { node, bounds: [[annotation.x, annotation.y], [annotation.x + approxWidth, annotation.y + fontSize]] }; + if (box.lines.length === 1) { + node.textContent = annotation.text; + } else { + box.lines.forEach((line, index) => { + const tspan = el("tspan", { + x: annotation.x, + y: annotation.y + index * box.fontSize * TEXT_LINE_HEIGHT, + "dominant-baseline": "hanging", + }); + tspan.textContent = line; + node.appendChild(tspan); + }); + } + return { node, bounds: [[box.x, box.y], [box.x + box.width, box.y + box.height]] }; } function renderLine(annotation, defs) { @@ -323,6 +438,7 @@ function renderPolygon(annotation) { const node = el("polygon", { points: points.map((p) => p.join(",")).join(" "), fill: annotation.fill || "rgba(255, 215, 0, 0.35)", + "fill-opacity": isNumber(annotation.fillOpacity) ? annotation.fillOpacity : undefined, stroke: annotation.stroke || DEFAULT_STROKE, "stroke-width": isNumber(annotation.strokeWidth) ? annotation.strokeWidth : 1.5, opacity: isNumber(annotation.opacity) ? annotation.opacity : undefined, @@ -330,6 +446,101 @@ function renderPolygon(annotation) { return { node, bounds: points }; } +/** + * An invisible, easier-to-click target over a drawn annotation: the text's + * whole box, or a wide stroke along a line. Polygons are clickable on their fill. + */ +function renderHitArea(annotation) { + if (annotation.type === "text") { + const box = textBox(annotation); + return el("rect", { x: box.x, y: box.y, width: box.width, height: box.height, class: "scene-annotation-hit" }); + } + if (annotation.type === "line" || annotation.type === "arrow") { + const width = isNumber(annotation.strokeWidth) ? annotation.strokeWidth : 2; + // A , not a , so it is never mistaken for the drawn line. + return el("path", { + d: linePoints(annotation).map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" "), + "stroke-width": Math.max(LINE_HIT_WIDTH, width + 8), + class: "scene-annotation-hit scene-annotation-hit-line", + }); + } + return null; +} + +/** + * The selection outline and handles for a drawn annotation: a resize handle + * on a text label's corner, or a handle on each point of a line or polygon. + */ +function renderAnnotationSelection(annotation, layer) { + if (annotation.type === "text") { + const box = textBox(annotation); + layer.appendChild(el("rect", { + class: "scene-selection-outline", + x: box.x - 3, + y: box.y - 3, + width: box.width + 6, + height: box.height + 6, + "pointer-events": "none", + })); + layer.appendChild(el("rect", { + class: "scene-resize-handle", + x: box.x + box.width + 3 - RESIZE_HANDLE_SIZE / 2, + y: box.y + box.height + 3 - RESIZE_HANDLE_SIZE / 2, + width: RESIZE_HANDLE_SIZE, + height: RESIZE_HANDLE_SIZE, + "data-annotation-handle": "resize", + })); + return; + } + const points = annotationPoints(annotation); + if (!points) return; + layer.appendChild(el(annotation.type === "polygon" ? "polygon" : "polyline", { + class: "scene-selection-outline", + points: points.map((p) => p.join(",")).join(" "), + "pointer-events": "none", + })); + if (points.length > MAX_POINT_HANDLES) return; + points.forEach(([x, y], index) => { + layer.appendChild(el("circle", { + class: "scene-point-handle", + cx: x, + cy: y, + r: POINT_HANDLE_RADIUS, + "data-annotation-handle": "point", + "data-point-index": index, + })); + }); +} + +/** The shape being drawn: a line or arrow being dragged, or a polygon's points so far. */ +function renderDraft(draft, defs, layer) { + const points = toPoints(draft.points); + if (!points || points.length === 0) return; + const group = el("g", { class: "scene-draft" }); + if (draft.type === "polygon") { + group.appendChild(el("polyline", { points: points.map((p) => p.join(",")).join(" "), class: "scene-draft-shape" })); + if (points.length >= 3) { + const [first, last] = [points[0], points[points.length - 1]]; + group.appendChild(el("line", { + x1: last[0], y1: last[1], x2: first[0], y2: first[1], class: "scene-draft-closing", + })); + } + points.forEach(([x, y], index) => { + group.appendChild(el("circle", { + cx: x, + cy: y, + r: index === 0 ? POINT_HANDLE_RADIUS + 2 : POINT_HANDLE_RADIUS - 2, + class: index === 0 ? "scene-draft-point scene-draft-start" : "scene-draft-point", + "data-draft-start": index === 0 ? "true" : undefined, + })); + }); + } else if (points.length >= 2) { + const result = renderLine({ ...draft, points }, defs); + if (result) group.appendChild(result.node); + } + layer.appendChild(group); +} + function renderAnnotation(annotation, defs) { switch (annotation.type) { case "text": @@ -351,14 +562,18 @@ function renderAnnotation(annotation, defs) { * `data-scene-image-index` attribute, letting a caller wire up click-to-select * without this renderer owning any selection state itself. * @param {{ images: object[], annotations: object[] }} scene - * @param {{ selectedIndex?: number, cropMode?: boolean }} [options] - cropMode + * @param {{ selectedIndex?: number, cropMode?: boolean, selectedAnnotationIndex?: number, + * draft?: { type: string, points: number[][] } }} [options] - cropMode * shows the selected image uncropped with handles on its *draft* crop rect * instead of the normal selection outline/resize handles (Issue #417). + * selectedAnnotationIndex outlines that annotation and adds its handles; + * each drawn annotation carries its index as `data-scene-annotation-index`. + * draft draws a shape that is still being created (Issues #431-#434). * @returns {{ svg: SVGSVGElement, rendered: number, unsupported: number, unlinked: number, * width: number, height: number }} `unlinked` counts linked annotations whose * source image is missing (drawn unchanged). */ -export function renderScene(scene, { selectedIndex, cropMode = false } = {}) { +export function renderScene(scene, { selectedIndex, cropMode = false, selectedAnnotationIndex, draft } = {}) { const svg = el("svg", { class: "scene-svg", role: "img" }); const defs = el("defs"); const imageLayer = el("g", { class: "scene-layer-images" }); @@ -423,7 +638,7 @@ export function renderScene(scene, { selectedIndex, cropMode = false } = {}) { if (image && typeof image.id === "string") imagesById.set(image.id, { image, index }); }); - for (const annotation of scene.annotations || []) { + (scene.annotations || []).forEach((annotation, index) => { let toRender = annotation; let clipPoints = null; if (annotation && annotation.sourceImageId !== undefined) { @@ -448,8 +663,16 @@ export function renderScene(scene, { selectedIndex, cropMode = false } = {}) { group.appendChild(result.node); result.node = group; } - place(result, annotationLayer); - } + if (!place(result, annotationLayer)) return; + result.node.setAttribute("data-scene-annotation-index", index); + const hitArea = renderHitArea(toRender); + if (hitArea) { + hitArea.setAttribute("data-scene-annotation-index", index); + annotationLayer.appendChild(hitArea); + } + if (index === selectedAnnotationIndex) renderAnnotationSelection(toRender, selectionLayer); + }); + if (draft) renderDraft(draft, defs, selectionLayer); const x = minX < 0 ? minX - PADDING : 0; const y = minY < 0 ? minY - PADDING : 0; diff --git a/templates/js/scenes.js b/templates/js/scenes.js index 71cf48b..c0109b5 100644 --- a/templates/js/scenes.js +++ b/templates/js/scenes.js @@ -1,4 +1,21 @@ -import { renderScene } from "./sceneCanvas.js"; +import { renderScene, displayedAnnotation, storeAnnotationGeometry, textBox } from "./sceneCanvas.js"; +import { + DEFAULT_HIGHLIGHT_COLOR, + DEFAULT_HIGHLIGHT_OPACITY, + DEFAULT_LINE_COLOR, + DEFAULT_TEXT_COLOR, + anchorOnBox, + clampHighlightOpacity, + clampStrokeWidth, + describeAnnotation, + isPointerLineFor, + labelEndIndex, + moveAnnotationPoint, + reanchorPointerLine, + resizeText, + toColorInputValue, + translateAnnotation, +} from "./sceneAnnotations.js"; const SCENES_URL = "/api/scenes"; @@ -176,6 +193,12 @@ const state = { busy: false, selectedImageIndex: null, cropMode: false, + // Issue #426: the selected annotation's index in `active.annotations`, the + // annotation tool in use ("text" | "line" | "arrow" | "polygon" | + // "pointer"), and the shape being drawn with it. + selectedAnnotationIndex: null, + tool: null, + draft: null, boneOptions: null, importRequest: 0, }; @@ -185,6 +208,10 @@ function deselectImage() { state.cropMode = false; } +function deselectAnnotation() { + state.selectedAnnotationIndex = null; +} + const clone = (value) => JSON.parse(JSON.stringify(value)); /** Whether the open scene has edits or imports that haven't been saved yet. */ @@ -203,6 +230,9 @@ function setActiveScene(scene) { state.missing = false; state.saveAlert = null; deselectImage(); + deselectAnnotation(); + state.tool = null; + state.draft = null; } /** The parts of a scene a save stores. */ @@ -478,7 +508,9 @@ function renderCanvas() { const scene = state.active; dom.canvas.replaceChildren(); updateImageToolbar(); - const isEmpty = scene.images.length === 0 && scene.annotations.length === 0; + updateAnnotationControls(); + // A blank scene still shows its canvas while a tool is drawing on it. + const isEmpty = scene.images.length === 0 && scene.annotations.length === 0 && !state.tool; dom.canvasEmpty.hidden = !isEmpty; dom.canvas.hidden = isEmpty; dom.fitToggle.hidden = isEmpty; @@ -488,6 +520,8 @@ function renderCanvas() { const { svg, unsupported, unlinked, width, height } = renderScene(scene, { selectedIndex: state.selectedImageIndex, cropMode: state.cropMode, + selectedAnnotationIndex: state.selectedAnnotationIndex, + draft: state.draft, }); svg.setAttribute("aria-label", `Scene canvas for ${scene.name}`); if (state.fitToWidth) { @@ -1234,6 +1268,545 @@ function resetSelectedImageCrop() { applySelectedImageUpdate({ cropX: 0, cropY: 0, cropWidth: image.width, cropHeight: image.height }); } +// --------------------------------------------------------------------------- +// Annotations (Issue #426: #427-#438) +// --------------------------------------------------------------------------- +// +// Labels, lines, arrows, and highlight polygons are created with a tool from +// the "Add annotation" row, then selected on the canvas to move, reshape, +// style, reorder, or delete them. Every change edits the working copy and is +// stored with the next Save, like image edits. Edits are made on the +// annotation as drawn and stored back through storeAnnotationGeometry, so an +// annotation imported with a bone keeps following its image. + +const NUDGE_STEP = 1; +const NUDGE_STEP_LARGE = 10; +// A line shorter than this (scene pixels) is treated as a stray click. +const MIN_LINE_LENGTH = 4; +// A polygon click this close to the last corner is the second half of a double-click. +const DUPLICATE_POINT_DISTANCE = 3; +const DEFAULT_LABEL_TEXT = "New label"; +const DEFAULT_HIGHLIGHT_STROKE = "#b8860b"; +const TOOL_HINTS = { + text: "Click the canvas where the label should go.", + line: "Drag on the canvas to draw the line.", + arrow: "Drag from the arrow's tail to the point it should point at.", + polygon: "Click to place each corner, then choose Finish shape, press Enter, or click the first corner.", + pointer: "Click the feature the label points to.", +}; + +const round2 = (value) => Math.round(value * 100) / 100; + +function selectedAnnotation() { + const scene = state.active; + const index = state.selectedAnnotationIndex; + if (!scene || index === null || index < 0 || index >= scene.annotations.length) return null; + return scene.annotations[index]; +} + +function selectAnnotation(index) { + deselectImage(); + state.selectedAnnotationIndex = index; +} + +/** Where a mouse event is in scene coordinates. */ +function clientToScene(svg, event) { + const rect = svg.getBoundingClientRect(); + const [viewX, viewY] = (svg.getAttribute("viewBox") || "0 0 0 0").split(" ").map(Number); + const scale = getSceneScale(svg); + return [ + round2(viewX + (event.clientX - (rect.left || 0)) * scale.x), + round2(viewY + (event.clientY - (rect.top || 0)) * scale.y), + ]; +} + +/** Sets the value of a toolbar control, unless the user is typing in it. */ +function setIdleValue(input, value) { + const text = String(value); + if (document.activeElement !== input && input.value !== text) input.value = text; +} + +/** Shows the annotation tools' state and the selected annotation's controls. */ +function updateAnnotationControls() { + if (!dom.annotationTools) return; + for (const button of dom.annotationTools.querySelectorAll("[data-tool]")) { + button.setAttribute("aria-pressed", String(state.tool === button.dataset.tool)); + } + dom.toolHint.hidden = !state.tool; + dom.toolHint.textContent = state.tool ? TOOL_HINTS[state.tool] : ""; + dom.toolCancel.hidden = !state.tool; + dom.polygonFinish.hidden = state.tool !== "polygon"; + dom.polygonFinish.disabled = !state.draft || state.draft.points.length < 3; + dom.canvas.classList.toggle("is-drawing", Boolean(state.tool)); + + const annotation = selectedAnnotation(); + dom.annotationToolbar.hidden = !annotation; + if (!annotation) { + dom.annotationTextError.hidden = true; + return; + } + const shown = displayedAnnotation(annotation, state.active); + const isText = annotation.type === "text"; + const isLine = annotation.type === "line" || annotation.type === "arrow"; + const isPolygon = annotation.type === "polygon"; + dom.annotationLabel.textContent = `Selected ${describeAnnotation(annotation)}:`; + dom.annotationTextField.hidden = !isText; + dom.annotationSizeField.hidden = !isText; + dom.annotationPointer.hidden = !isText; + dom.annotationWidthField.hidden = !isLine; + dom.annotationOpacityField.hidden = !isPolygon; + + if (isText) { + if (dom.annotationTextError.hidden) setIdleValue(dom.annotationText, annotation.text); + setIdleValue(dom.annotationSize, Math.round(textBox(shown).fontSize * 10) / 10); + setIdleValue(dom.annotationColor, toColorInputValue(annotation.color, DEFAULT_TEXT_COLOR)); + } else if (isLine) { + setIdleValue(dom.annotationWidth, round2(typeof shown.strokeWidth === "number" ? shown.strokeWidth : 2)); + setIdleValue(dom.annotationColor, toColorInputValue(annotation.stroke, DEFAULT_LINE_COLOR)); + } else if (isPolygon) { + setIdleValue(dom.annotationColor, toColorInputValue(annotation.fill, DEFAULT_HIGHLIGHT_COLOR)); + const opacity = typeof annotation.fillOpacity === "number" ? annotation.fillOpacity + : typeof annotation.opacity === "number" ? annotation.opacity + : DEFAULT_HIGHLIGHT_OPACITY; + setIdleValue(dom.annotationOpacity, clampHighlightOpacity(opacity)); + } + + const last = state.active.annotations.length - 1; + dom.annotationBack.disabled = state.selectedAnnotationIndex === 0; + dom.annotationBackward.disabled = state.selectedAnnotationIndex === 0; + dom.annotationForward.disabled = state.selectedAnnotationIndex === last; + dom.annotationFront.disabled = state.selectedAnnotationIndex === last; +} + +/** + * Starts (or, given null or the tool already in use, stops) an annotation + * tool. Any other selection is cleared, except that the pointer tool keeps + * its label selected. + * @param {"text"|"line"|"arrow"|"polygon"|"pointer"|null} tool + */ +function setTool(tool) { + if (!state.active) return; + const next = tool === state.tool ? null : tool; + if (next === "pointer" && (!selectedAnnotation() || selectedAnnotation().type !== "text")) return; + state.tool = next; + state.draft = null; + if (next && next !== "pointer") { + deselectImage(); + deselectAnnotation(); + } + renderCanvas(); + if (next) { + announce(TOOL_HINTS[next]); + dom.canvas.focus(); + } +} + +function cancelTool() { + if (!state.tool) return; + state.tool = null; + state.draft = null; + renderCanvas(); + announce("Drawing cancelled."); +} + +/** Adds a new annotation on top of the others and selects it. */ +function addAnnotation(annotation) { + state.active.annotations.push(annotation); + state.tool = null; + state.draft = null; + selectAnnotation(state.active.annotations.length - 1); + markChanged(); + renderWorkspace(); +} + +/** Issue #427: places a new label with placeholder text, ready to type over. */ +function placeTextLabel([x, y]) { + addAnnotation({ + id: crypto.randomUUID(), + type: "text", + text: DEFAULT_LABEL_TEXT, + x, + y, + fontSize: 18, + fontWeight: "600", + color: DEFAULT_TEXT_COLOR, + }); + announce("Label added. Type its text in the Text box. Save to keep it."); + dom.annotationText.focus(); + dom.annotationText.select(); +} + +/** + * Issue #432: adds a pointer line from the selected label to `target`. It + * starts on the label's edge, records the label's id, and follows the label's + * image if the label is linked to one. + */ +function addPointerLine(target) { + const label = selectedAnnotation(); + if (!label || label.type !== "text") { + cancelTool(); + return; + } + const scene = state.active; + if (typeof label.id !== "string") label.id = crypto.randomUUID(); + const [x1, y1] = anchorOnBox(textBox(displayedAnnotation(label, scene)), target); + const line = { + id: crypto.randomUUID(), + type: "line", + x1, + y1, + x2: target[0], + y2: target[1], + stroke: toColorInputValue(label.color, DEFAULT_LINE_COLOR), + strokeWidth: 1.5, + labelId: label.id, + }; + if (label.sourceImageId !== undefined && label.sourceImageFrame) { + line.sourceImageId = label.sourceImageId; + line.sourceImageFrame = { ...label.sourceImageFrame }; + storeAnnotationGeometry(line, { ...line }, scene); + } + addAnnotation(line); + announce("Pointer line added. Drag its ends to adjust it. Save to keep it."); + dom.canvas.focus(); +} + +/** Issues #431/#433: draws a line or arrow by dragging from its start to its end. */ +function startLineDraw(event, svg) { + const type = state.tool; + const start = clientToScene(svg, event); + const scale = getSceneScale(svg); + const startClientX = event.clientX; + const startClientY = event.clientY; + state.draft = { type, points: [start, start] }; + + function onMouseMove(moveEvent) { + state.draft.points[1] = [ + round2(start[0] + (moveEvent.clientX - startClientX) * scale.x), + round2(start[1] + (moveEvent.clientY - startClientY) * scale.y), + ]; + renderCanvas(); + } + + function onMouseUp() { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + suppressNextClick = true; + if (!state.draft) return; + const [[x1, y1], [x2, y2]] = state.draft.points; + if (Math.hypot(x2 - x1, y2 - y1) < MIN_LINE_LENGTH) { + state.draft = null; + renderCanvas(); + announce(`Drag on the canvas to draw the ${type}.`); + return; + } + addAnnotation({ id: crypto.randomUUID(), type, x1, y1, x2, y2, stroke: DEFAULT_LINE_COLOR, strokeWidth: 2 }); + announce(`${type === "arrow" ? "Arrow" : "Line"} added. Drag its ends to adjust it. Save to keep it.`); + dom.canvas.focus(); + } + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + renderCanvas(); +} + +/** Issue #434: adds a corner to the highlight being drawn, or finishes it on its first corner. */ +function addPolygonPoint(point, target) { + if (!state.draft) state.draft = { type: "polygon", points: [] }; + const points = state.draft.points; + if (points.length >= 3 && target && target.closest("[data-draft-start]")) { + finishPolygon(); + return; + } + const last = points[points.length - 1]; + if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) < DUPLICATE_POINT_DISTANCE) return; + points.push(point); + renderCanvas(); +} + +function finishPolygon() { + const points = state.draft ? state.draft.points : []; + if (points.length < 3) { + announce("A highlight needs at least 3 corners."); + return; + } + addAnnotation({ + id: crypto.randomUUID(), + type: "polygon", + points: points.map((point) => [...point]), + fill: DEFAULT_HIGHLIGHT_COLOR, + fillOpacity: DEFAULT_HIGHLIGHT_OPACITY, + stroke: DEFAULT_HIGHLIGHT_STROKE, + strokeWidth: 1.5, + }); + announce("Highlight added. Drag its corners to reshape it. Save to keep it."); + dom.canvas.focus(); +} + +/** The selected label's pointer lines, each with its drawn geometry before the edit. */ +function pointerLinesOf(label, scene) { + if (label.type !== "text") return []; + return scene.annotations + .filter((other) => other !== label && isPointerLineFor(other, label)) + .map((line) => ({ line, start: clone(displayedAnnotation(line, scene)) })); +} + +/** Moves the label end of each pointer line by (dx, dy), so it stays on its moved label. */ +function movePointerLines(pointers, labelBox, dx, dy, scene) { + for (const { line, start } of pointers) { + storeAnnotationGeometry(line, moveAnnotationPoint(start, labelEndIndex(start, labelBox), dx, dy), scene); + } +} + +/** Puts each pointer line created for a label back on its edge after the label changed size or text. */ +function reanchorPointerLines(label, scene) { + const shownLabel = displayedAnnotation(label, scene); + for (const line of scene.annotations) { + if (line === label || typeof line.labelId !== "string" || !isPointerLineFor(line, label)) continue; + storeAnnotationGeometry(line, reanchorPointerLine(displayedAnnotation(line, scene), shownLabel), scene); + } +} + +/** + * Issues #429/#431/#433/#435: drags the selected annotation. "move" moves all + * of it (and a label's pointer lines with it), "point" moves one line end or + * polygon corner, and "resize" scales a label's text from its top-left corner. + * @param {MouseEvent} event + * @param {SVGSVGElement} svg + * @param {"move"|"point"|"resize"} mode + * @param {number} [pointIndex] + */ +function startAnnotationDrag(event, svg, mode, pointIndex) { + const scene = state.active; + const annotation = selectedAnnotation(); + const start = clone(displayedAnnotation(annotation, scene)); + const startBox = annotation.type === "text" ? textBox(start) : null; + const pointers = pointerLinesOf(annotation, scene); + const scale = getSceneScale(svg); + const startClientX = event.clientX; + const startClientY = event.clientY; + let didDrag = false; + + function onMouseMove(moveEvent) { + const dxClient = moveEvent.clientX - startClientX; + const dyClient = moveEvent.clientY - startClientY; + if (Math.abs(dxClient) > DRAG_THRESHOLD || Math.abs(dyClient) > DRAG_THRESHOLD) didDrag = true; + if (!didDrag) return; + const dx = dxClient * scale.x; + const dy = dyClient * scale.y; + + if (mode === "point") { + storeAnnotationGeometry(annotation, moveAnnotationPoint(start, pointIndex, dx, dy), scene); + } else if (mode === "resize" && startBox) { + const factor = startBox.width > 0 ? (startBox.width + dx) / startBox.width : 1; + storeAnnotationGeometry(annotation, resizeText(start, startBox.fontSize * factor), scene); + reanchorPointerLines(annotation, scene); + } else { + storeAnnotationGeometry(annotation, translateAnnotation(start, dx, dy), scene); + movePointerLines(pointers, startBox, dx, dy, scene); + } + renderWorkspace(); + } + + function onMouseUp() { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + suppressNextClick = didDrag; + if (didDrag) markChanged(); + } + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); +} + +/** Moves the selected annotation by a few scene pixels with the arrow keys. */ +function nudgeSelectedAnnotation(dx, dy) { + const scene = state.active; + const annotation = selectedAnnotation(); + if (!annotation) return; + const start = displayedAnnotation(annotation, scene); + const startBox = annotation.type === "text" ? textBox(start) : null; + const pointers = pointerLinesOf(annotation, scene); + storeAnnotationGeometry(annotation, translateAnnotation(start, dx, dy), scene); + movePointerLines(pointers, startBox, dx, dy, scene); + markChanged(); + renderWorkspace(); +} + +/** + * Applies a toolbar change to the selected annotation. + * @param {(annotation: object, scene: object) => void} edit + * @param {{ reanchor?: boolean }} [options] - reanchor puts a label's + * pointer lines back on its edge after its size or text changed. + */ +function editSelectedAnnotation(edit, { reanchor = false } = {}) { + const annotation = selectedAnnotation(); + if (!annotation) return; + const scene = state.active; + edit(annotation, scene); + if (reanchor) reanchorPointerLines(annotation, scene); + markChanged(); + renderWorkspace(); +} + +/** Issue #428: changes the selected label's text as the user types. An empty label isn't applied. */ +function handleAnnotationTextInput() { + const text = dom.annotationText.value; + if (!text.trim()) { + dom.annotationTextError.hidden = false; + dom.annotationTextError.textContent = "A label needs some text. Type its text, or use Delete annotation to remove it."; + dom.annotationText.setAttribute("aria-invalid", "true"); + return; + } + dom.annotationTextError.hidden = true; + dom.annotationText.removeAttribute("aria-invalid"); + editSelectedAnnotation((annotation) => { + annotation.text = text; + }, { reanchor: true }); +} + +/** Leaving the text box empty keeps the label's last text. */ +function handleAnnotationTextBlur() { + if (dom.annotationTextError.hidden) return; + dom.annotationTextError.hidden = true; + dom.annotationText.removeAttribute("aria-invalid"); + const annotation = selectedAnnotation(); + if (annotation && annotation.type === "text") { + dom.annotationText.value = annotation.text; + announce(`The label wasn't left empty; it still reads "${annotation.text}".`); + } +} + +function handleAnnotationSizeChange() { + const fontSize = Number(dom.annotationSize.value); + if (!Number.isFinite(fontSize) || fontSize <= 0) return; + editSelectedAnnotation((annotation, scene) => { + storeAnnotationGeometry(annotation, resizeText(displayedAnnotation(annotation, scene), fontSize), scene); + }, { reanchor: true }); +} + +function handleAnnotationWidthChange() { + const width = Number(dom.annotationWidth.value); + if (!Number.isFinite(width) || width <= 0) return; + editSelectedAnnotation((annotation, scene) => { + const shown = displayedAnnotation(annotation, scene); + storeAnnotationGeometry(annotation, { ...shown, strokeWidth: clampStrokeWidth(width) }, scene); + }); +} + +/** Issue #436 (and the color of labels, lines, and arrows). */ +function handleAnnotationColorInput() { + const color = dom.annotationColor.value; + editSelectedAnnotation((annotation) => { + if (annotation.type === "text") { + annotation.color = color; + } else if (annotation.type === "polygon") { + annotation.fill = color; + annotation.stroke = color; + } else { + annotation.stroke = color; + } + }); +} + +/** + * Issue #436: a highlight's opacity applies to its fill only, so its outline + * stays clear. An imported region's whole-shape `opacity` is replaced by it. + */ +function handleAnnotationOpacityInput() { + const opacity = Number(dom.annotationOpacity.value); + if (!Number.isFinite(opacity)) return; + editSelectedAnnotation((annotation) => { + annotation.fillOpacity = clampHighlightOpacity(opacity); + delete annotation.opacity; + }); +} + +/** + * Issue #438: moves the selected annotation up or down the annotation stack. + * Images are always drawn under annotations, so their order never changes. + * @param {1|-1|"front"|"back"} direction + */ +function moveSelectedAnnotation(direction) { + const annotations = state.active ? state.active.annotations : null; + const index = state.selectedAnnotationIndex; + if (!annotations || !selectedAnnotation()) return; + const last = annotations.length - 1; + const target = direction === "front" ? last : direction === "back" ? 0 : index + direction; + if (target < 0 || target > last || target === index) return; + const [annotation] = annotations.splice(index, 1); + annotations.splice(target, 0, annotation); + state.selectedAnnotationIndex = target; + markChanged(); + renderWorkspace(); + const position = target === last ? "in front of all other annotations" + : target === 0 ? "behind all other annotations" + : `at layer ${target + 1} of ${annotations.length}`; + announce(`The ${describeAnnotation(annotation)} is now ${position}.`); +} + +/** Issues #430/#437: deletes the selected annotation, and a label's pointer lines with it. */ +function deleteSelectedAnnotation() { + const annotation = selectedAnnotation(); + if (!annotation) return; + const scene = state.active; + const removed = new Set([annotation, ...pointerLinesOf(annotation, scene).map(({ line }) => line)]); + scene.annotations = scene.annotations.filter((other) => !removed.has(other)); + deselectAnnotation(); + markChanged(); + renderWorkspace(); + const kind = describeAnnotation(annotation); + announce(removed.size > 1 + ? `Label and its ${plural(removed.size - 1, "pointer line")} deleted. Save to keep the change.` + : `${kind.charAt(0).toUpperCase()}${kind.slice(1)} deleted. Save to keep the change.`); + dom.canvas.focus(); +} + +/** A click on the canvas while an annotation tool is in use. */ +function handleToolClick(event) { + const svg = dom.canvas.querySelector("svg"); + if (!svg) return; + const point = clientToScene(svg, event); + if (state.tool === "text") placeTextLabel(point); + else if (state.tool === "pointer") addPointerLine(point); + else if (state.tool === "polygon") addPolygonPoint(point, event.target); +} + +/** Keyboard use of annotations on the canvas. Returns whether the key was handled. */ +function handleAnnotationKey(event) { + if (event.key === "Escape" && state.tool) { + cancelTool(); + return true; + } + if (state.tool === "polygon") { + if (event.key === "Enter") { + finishPolygon(); + return true; + } + if (event.key === "Backspace" && state.draft && state.draft.points.length > 0) { + state.draft.points.pop(); + renderCanvas(); + return true; + } + return false; + } + if (state.tool || !selectedAnnotation()) return false; + if (event.key === "Escape") { + deselectAnnotation(); + renderCanvas(); + return true; + } + if (event.key === "Delete" || event.key === "Backspace") { + deleteSelectedAnnotation(); + return true; + } + const step = event.shiftKey ? NUDGE_STEP_LARGE : NUDGE_STEP; + const deltas = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }; + if (!deltas[event.key]) return false; + nudgeSelectedAnnotation(...deltas[event.key]); + return true; +} + /** * Imports one or more image files into the currently open scene as new, * unselected image objects in the working copy (Issues #411/#412). They're @@ -1616,6 +2189,28 @@ export function initializeSceneEditor(root = document) { importExtras: $("scene-import-extras"), importLabels: $("scene-import-labels"), importRegions: $("scene-import-regions"), + annotationTools: $("scene-annotation-tools"), + toolHint: $("scene-tool-hint"), + toolCancel: $("scene-tool-cancel"), + polygonFinish: $("scene-polygon-finish"), + annotationToolbar: $("scene-annotation-toolbar"), + annotationLabel: $("scene-annotation-label"), + annotationTextField: $("scene-annotation-text-field"), + annotationText: $("scene-annotation-text"), + annotationTextError: $("scene-annotation-text-error"), + annotationSizeField: $("scene-annotation-size-field"), + annotationSize: $("scene-annotation-size"), + annotationWidthField: $("scene-annotation-width-field"), + annotationWidth: $("scene-annotation-width"), + annotationColor: $("scene-annotation-color"), + annotationOpacityField: $("scene-annotation-opacity-field"), + annotationOpacity: $("scene-annotation-opacity"), + annotationPointer: $("scene-annotation-pointer"), + annotationBack: $("scene-annotation-back"), + annotationBackward: $("scene-annotation-backward"), + annotationForward: $("scene-annotation-forward"), + annotationFront: $("scene-annotation-front"), + annotationDelete: $("scene-annotation-delete"), }; if (!dom.editorView || !dom.enterButton) return; @@ -1668,6 +2263,21 @@ export function initializeSceneEditor(root = document) { suppressNextClick = false; return; } + if (state.tool) { + handleToolClick(event); + return; + } + if (event.target.closest("[data-annotation-handle]")) return; + const annotationTarget = event.target.closest("[data-scene-annotation-index]"); + if (annotationTarget) { + if (state.cropMode) return; + const annotationIndex = Number(annotationTarget.dataset.sceneAnnotationIndex); + if (state.selectedAnnotationIndex === annotationIndex) deselectAnnotation(); + else selectAnnotation(annotationIndex); + renderCanvas(); + return; + } + deselectAnnotation(); const target = event.target.closest("[data-scene-image-index]"); const index = target ? Number(target.dataset.sceneImageIndex) : null; if (state.selectedImageIndex === index) { @@ -1677,14 +2287,61 @@ export function initializeSceneEditor(root = document) { } renderCanvas(); }); + dom.canvas.addEventListener("dblclick", (event) => { + if (state.tool === "polygon") { + finishPolygon(); + return; + } + if (state.tool) return; + const target = event.target.closest("[data-scene-annotation-index]"); + if (!target) return; + const index = Number(target.dataset.sceneAnnotationIndex); + const annotation = state.active && state.active.annotations[index]; + if (!annotation || annotation.type !== "text") return; + selectAnnotation(index); + renderCanvas(); + dom.annotationText.focus(); + dom.annotationText.select(); + }); dom.canvas.addEventListener("keydown", (event) => { + if (handleAnnotationKey(event)) { + event.preventDefault(); + return; + } if (event.key === "Escape" && state.selectedImageIndex !== null) { deselectImage(); renderCanvas(); } }); dom.canvas.addEventListener("mousedown", (event) => { - if (!state.active || state.selectedImageIndex === null) return; + // A drag's own click may never arrive (its node was redrawn away), so + // a new press always starts with clicks counting again. + suppressNextClick = false; + if (!state.active) return; + if (state.tool === "line" || state.tool === "arrow") { + const drawingSvg = dom.canvas.querySelector("svg"); + if (!drawingSvg) return; + event.preventDefault(); + startLineDraw(event, drawingSvg); + return; + } + if (state.tool) return; + if (selectedAnnotation()) { + const annotationSvg = dom.canvas.querySelector("svg"); + const annotationHandle = event.target.closest("[data-annotation-handle]"); + const annotationNode = event.target.closest("[data-scene-annotation-index]"); + if (!annotationSvg) return; + if (annotationHandle) { + event.preventDefault(); + startAnnotationDrag(event, annotationSvg, annotationHandle.dataset.annotationHandle, + Number(annotationHandle.dataset.pointIndex)); + } else if (annotationNode && Number(annotationNode.dataset.sceneAnnotationIndex) === state.selectedAnnotationIndex) { + event.preventDefault(); + startAnnotationDrag(event, annotationSvg, "move"); + } + return; + } + if (state.selectedImageIndex === null) return; const image = state.active.images[state.selectedImageIndex]; if (!image) return; @@ -1721,6 +2378,32 @@ export function initializeSceneEditor(root = document) { dom.imageFlipHorizontal.addEventListener("click", () => flipSelectedImage("horizontal")); dom.imageFlipVertical.addEventListener("click", () => flipSelectedImage("vertical")); + if (dom.annotationTools) { + for (const button of dom.annotationTools.querySelectorAll("[data-tool]")) { + button.addEventListener("click", () => setTool(button.dataset.tool)); + } + dom.annotationTools.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.tool) cancelTool(); + }); + dom.toolCancel.addEventListener("click", () => { + cancelTool(); + dom.canvas.focus(); + }); + dom.polygonFinish.addEventListener("click", finishPolygon); + dom.annotationText.addEventListener("input", handleAnnotationTextInput); + dom.annotationText.addEventListener("blur", handleAnnotationTextBlur); + dom.annotationSize.addEventListener("change", handleAnnotationSizeChange); + dom.annotationWidth.addEventListener("change", handleAnnotationWidthChange); + dom.annotationColor.addEventListener("input", handleAnnotationColorInput); + dom.annotationOpacity.addEventListener("input", handleAnnotationOpacityInput); + dom.annotationPointer.addEventListener("click", () => setTool("pointer")); + dom.annotationBack.addEventListener("click", () => moveSelectedAnnotation("back")); + dom.annotationBackward.addEventListener("click", () => moveSelectedAnnotation(-1)); + dom.annotationForward.addEventListener("click", () => moveSelectedAnnotation(1)); + dom.annotationFront.addEventListener("click", () => moveSelectedAnnotation("front")); + dom.annotationDelete.addEventListener("click", deleteSelectedAnnotation); + } + renderWorkspace(); } diff --git a/templates/style.css b/templates/style.css index 659e2c4..b10000f 100644 --- a/templates/style.css +++ b/templates/style.css @@ -3008,6 +3008,99 @@ button:disabled { stroke-dasharray: 4 3; } +/* Annotations (Issue #426) */ +.scene-annotation-tools .scene-btn[aria-pressed="true"] { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +.scene-tool-hint { + font-size: 0.9rem; + color: var(--text-muted); +} + +.scene-annotation-toolbar { + align-items: flex-end; +} + +.scene-annotation-field { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.85rem; + color: var(--text-muted); +} + +.scene-annotation-field input[type="number"] { + width: 5rem; +} + +.scene-annotation-text-field { + flex: 1 1 14rem; +} + +.scene-annotation-text-field textarea { + width: 100%; + min-height: 2.5rem; + font: inherit; + resize: vertical; +} + +.scene-annotation-text-field .scene-field-error { + margin: 0; +} + +.scene-layer-annotations [data-scene-annotation-index] { + cursor: pointer; +} + +.scene-annotation-hit { + fill: transparent; + stroke: none; +} + +.scene-annotation-hit-line { + fill: none; + stroke: transparent; + pointer-events: stroke; +} + +.scene-point-handle { + fill: #ffffff; + stroke: var(--accent); + stroke-width: 1.5; + cursor: move; +} + +.scene-canvas.is-drawing, +.scene-canvas.is-drawing [data-scene-annotation-index], +.scene-canvas.is-drawing .scene-image-object { + cursor: crosshair; +} + +.scene-draft-shape, +.scene-draft-closing { + fill: none; + stroke: var(--accent); + stroke-width: 2; +} + +.scene-draft-closing { + stroke-dasharray: 5 4; +} + +.scene-draft-point { + fill: #ffffff; + stroke: var(--accent); + stroke-width: 1.5; +} + +.scene-draft-start { + fill: var(--accent); + cursor: pointer; +} + @media (max-width: 900px) { .scene-editor-layout { grid-template-columns: 1fr; diff --git a/templates/tests/sceneAnnotationEditor.test.js b/templates/tests/sceneAnnotationEditor.test.js new file mode 100644 index 0000000..9ac07ac --- /dev/null +++ b/templates/tests/sceneAnnotationEditor.test.js @@ -0,0 +1,665 @@ +// Tests for adding and editing annotations in the Scene Editor (Issue #426: +// #427-#438). Each change edits the open working copy; Save stores it, and +// reopening the scene shows exactly what was saved. +const fs = require("fs"); +const path = require("path"); + +const pageHtml = fs.readFileSync(path.join(__dirname, "..", "boneset.html"), "utf8"); +const bodyHtml = pageHtml.match(/]*>([\s\S]*)<\/body>/i)[1].replace(//gi, ""); + +const V0 = "2026-10-01T00:00:00.000Z"; + +// In-memory stand-in for the parts of /api/scenes these tests use. +function createFakeBackend() { + const scenes = new Map(); + let version = 0; + const respond = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => JSON.parse(JSON.stringify(body)) }); + + const fetch = jest.fn(async (url, options = {}) => { + const method = (options.method || "GET").toUpperCase(); + const body = options.body ? JSON.parse(options.body) : undefined; + const id = decodeURIComponent(url.match(/^\/api\/scenes\/?([^/]*)/)[1]) || null; + if (!id) { + return respond(200, { + scenes: [...scenes.values()].map((s) => ({ + id: s.id, name: s.name, imageCount: s.images.length, annotationCount: s.annotations.length, updatedAt: s.updatedAt, + })), + }); + } + const scene = scenes.get(id); + if (!scene) return respond(404, { error: "Scene not found" }); + if (method === "GET") return respond(200, scene); + if (method === "PUT") { + if (body.baseUpdatedAt && body.baseUpdatedAt !== scene.updatedAt) return respond(412, { error: "Changed", updatedAt: scene.updatedAt }); + scene.images = body.images; + scene.annotations = body.annotations; + scene.updatedAt = new Date(Date.parse(V0) + (version += 1) * 1000).toISOString(); + return respond(200, scene); + } + return respond(405, { error: "Method not allowed" }); + }); + + return { + fetch, + scenes, + seed(scene) { + scenes.set(scene.id, { images: [], annotations: [], name: scene.id, updatedAt: V0, ...scene }); + }, + }; +} + +async function waitFor(check, timeout = 1000) { + const start = Date.now(); + for (;;) { + try { + return check(); + } catch (error) { + if (Date.now() - start > timeout) throw error; + } + await new Promise((r) => setTimeout(r, 5)); + } +} + +const $ = (id) => document.getElementById(id); +const click = (id) => $(id).click(); +const canvasSvg = () => document.querySelector("#scene-canvas svg"); +const annotations = () => scenesModule.getActiveScene().annotations; +const stored = () => backend.scenes.get("scene-1").annotations; +const annotationNode = (index) => document.querySelector(`#scene-canvas [data-scene-annotation-index="${index}"]`); + +// jsdom gives the SVG no on-screen size, so the editor maps client +// coordinates 1:1 onto the scene's 960x600 viewBox. +function mouse(type, target, [x, y]) { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: x, clientY: y })); +} +// A full press, as a browser sends it: the click follows mousedown and mouseup. +function clickAt(target, point) { + mouse("mousedown", target, point); + document.dispatchEvent(new MouseEvent("mouseup", { clientX: point[0], clientY: point[1] })); + mouse("click", target, point); +} +function drag(target, from, to) { + mouse("mousedown", target, from); + document.dispatchEvent(new MouseEvent("mousemove", { clientX: to[0], clientY: to[1] })); + document.dispatchEvent(new MouseEvent("mouseup", { clientX: to[0], clientY: to[1] })); +} +function key(name, options = {}) { + $("scene-canvas").dispatchEvent(new KeyboardEvent("keydown", { key: name, bubbles: true, ...options })); +} +function selectAnnotation(index) { + clickAt(annotationNode(index), [0, 0]); +} +function typeText(text) { + $("scene-annotation-text").focus(); + $("scene-annotation-text").value = text; + $("scene-annotation-text").dispatchEvent(new Event("input", { bubbles: true })); +} +function setField(id, value, eventType) { + $(id).value = String(value); + $(id).dispatchEvent(new Event(eventType, { bubbles: true })); +} + +let backend; +let scenesModule; + +function startEditor() { + document.body.innerHTML = bodyHtml; + global.fetch = backend.fetch; + jest.resetModules(); + scenesModule = require("../js/scenes.js"); + scenesModule.initializeSceneEditor(); +} + +async function openScene() { + click("text-button-SceneEditor"); + await waitFor(() => expect(document.querySelector(".scene-list-item")).not.toBeNull()); + [...document.querySelectorAll(".scene-list-item")].find((b) => b.textContent.includes("Plate")).click(); + await waitFor(() => expect($("scene-title").textContent).toBe("Plate")); +} + +async function saveAndWait() { + click("scene-save"); + await waitFor(() => expect($("scene-save-status").textContent).toBe("Saved")); +} + +/** Saves, then loads the scene again in a fresh editor, as reopening the page would. */ +async function saveAndReopen() { + await saveAndWait(); + startEditor(); + await openScene(); +} + +beforeEach(() => { + backend = createFakeBackend(); + if (!global.crypto) global.crypto = {}; + let uuid = 0; + jest.spyOn(global.crypto, "randomUUID").mockImplementation(() => `uuid-${(uuid += 1)}`); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +async function openBlankScene(content = {}) { + backend.seed({ id: "scene-1", name: "Plate", ...content }); + startEditor(); + await openScene(); +} + +async function addLabel(point, text) { + click("scene-tool-text"); + clickAt(canvasSvg(), point); + if (text !== undefined) typeText(text); +} + +describe("Text labels (#427-#430)", () => { + it("draws a blank scene's canvas while a tool is in use, so a label can be placed on it", async () => { + await openBlankScene(); + expect($("scene-canvas").hidden).toBe(true); + + click("scene-tool-text"); + + expect($("scene-canvas").hidden).toBe(false); + expect($("scene-tool-text").getAttribute("aria-pressed")).toBe("true"); + expect($("scene-tool-hint").textContent).toMatch(/Click the canvas/); + key("Escape"); + expect($("scene-tool-text").getAttribute("aria-pressed")).toBe("false"); + expect($("scene-canvas").hidden).toBe(true); + }); + + it("places a selectable label at the click and keeps short, long, and multi-line text after reopening (#427)", async () => { + await openBlankScene(); + await addLabel([100, 120]); + + expect(annotations()).toEqual([expect.objectContaining({ type: "text", text: "New label", x: 100, y: 120 })]); + expect($("scene-annotation-toolbar").hidden).toBe(false); + expect($("scene-annotation-label").textContent).toBe("Selected label:"); + expect(document.activeElement).toBe($("scene-annotation-text")); + expect(document.querySelectorAll(".scene-selection-outline")).toHaveLength(1); + + typeText("Ilium"); + await addLabel([100, 200], "Anterior superior iliac spine of the ilium"); + await addLabel([100, 300], "Iliac\ncrest"); + // Labels are scene objects, never painted into an image. + expect(backend.scenes.get("scene-1").images).toEqual([]); + + await saveAndReopen(); + + expect(stored().map((a) => a.text)).toEqual(["Ilium", "Anterior superior iliac spine of the ilium", "Iliac\ncrest"]); + expect(canvasSvg().querySelector(".scene-layer-annotations text").textContent).toBe("Ilium"); + expect([...annotationNode(2).querySelectorAll("tspan")].map((t) => t.textContent)).toEqual(["Iliac", "crest"]); + selectAnnotation(0); + expect($("scene-annotation-text").value).toBe("Ilium"); + }); + + it("edits a label several times and keeps the last text (#428)", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] }); + selectAnnotation(0); + + typeText("Ilum"); + typeText("Ischium"); + expect(annotationNode(0).textContent).toBe("Ischium"); + expect($("scene-save-status").textContent).toBe("Unsaved changes"); + await saveAndReopen(); + expect(stored()[0].text).toBe("Ischium"); + + selectAnnotation(0); + typeText("Pubis"); + await saveAndReopen(); + expect(stored()[0].text).toBe("Pubis"); + }); + + it("explains an empty label and keeps the last text instead of storing an empty one (#428)", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] }); + selectAnnotation(0); + + typeText(" "); + + expect($("scene-annotation-text-error").hidden).toBe(false); + expect($("scene-annotation-text-error").textContent).toMatch(/needs some text/); + expect($("scene-annotation-text").getAttribute("aria-invalid")).toBe("true"); + expect(annotations()[0].text).toBe("Ilium"); + expect(scenesModule.hasUnsavedChanges()).toBe(false); + + $("scene-annotation-text").dispatchEvent(new Event("blur")); + expect($("scene-annotation-text").value).toBe("Ilium"); + expect($("scene-annotation-text-error").hidden).toBe(true); + }); + + it("opens a label's text for editing on double-click", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] }); + mouse("dblclick", annotationNode(0), [0, 0]); + expect(document.activeElement).toBe($("scene-annotation-text")); + expect($("scene-annotation-toolbar").hidden).toBe(false); + }); + + it("moves and resizes a label near the edge and keeps the change (#429)", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10, fontSize: 20 }] }); + selectAnnotation(0); + + drag(annotationNode(0), [15, 15], [5, 595]); + expect(annotations()[0]).toMatchObject({ x: 0, y: 590 }); + // The click a browser sends after the drag's mouseup doesn't deselect the label. + mouse("click", annotationNode(0), [5, 595]); + expect($("scene-annotation-toolbar").hidden).toBe(false); + + // The label is 60 wide at 20px; dragging its corner 60 to the right doubles it. + drag(document.querySelector("[data-annotation-handle=\"resize\"]"), [63, 613], [123, 613]); + expect(annotations()[0]).toMatchObject({ x: 0, y: 590, fontSize: 40 }); + expect($("scene-annotation-size").value).toBe("40"); + + setField("scene-annotation-size", 2, "change"); + expect(annotations()[0].fontSize).toBe(8); + + setField("scene-annotation-size", 24, "change"); + await saveAndReopen(); + expect(stored()[0]).toMatchObject({ x: 0, y: 590, fontSize: 24 }); + }); + + it("nudges the selected label with the arrow keys", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] }); + selectAnnotation(0); + key("ArrowRight"); + key("ArrowDown", { shiftKey: true }); + expect(annotations()[0]).toMatchObject({ x: 11, y: 20 }); + }); + + it("deletes one label from several and leaves the others (#430)", async () => { + await openBlankScene({ + annotations: [ + { id: "a", type: "text", text: "Ilium", x: 10, y: 10 }, + { id: "b", type: "text", text: "Ischium", x: 10, y: 40 }, + { id: "c", type: "text", text: "Pubis", x: 10, y: 70 }, + ], + }); + backend.seed({ id: "scene-2", name: "Other", annotations: [{ id: "b", type: "text", text: "Ischium", x: 0, y: 0 }] }); + selectAnnotation(1); + + click("scene-annotation-delete"); + + expect(annotations().map((a) => a.id)).toEqual(["a", "c"]); + expect($("scene-annotation-toolbar").hidden).toBe(true); + await saveAndReopen(); + expect(stored()).toEqual([ + { id: "a", type: "text", text: "Ilium", x: 10, y: 10 }, + { id: "c", type: "text", text: "Pubis", x: 10, y: 70 }, + ]); + expect(backend.scenes.get("scene-2").annotations).toHaveLength(1); + }); + + it("deletes the selected label with the Delete key", async () => { + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] }); + selectAnnotation(0); + key("Delete"); + expect(annotations()).toEqual([]); + }); +}); + +describe("Lines, pointer lines, and arrows (#431-#433)", () => { + it("draws a line by dragging and keeps lines at different lengths and angles (#431)", async () => { + await openBlankScene(); + + click("scene-tool-line"); + drag(canvasSvg(), [100, 100], [300, 100]); + click("scene-tool-line"); + drag(canvasSvg(), [50, 400], [80, 350]); + + expect(annotations()).toEqual([ + expect.objectContaining({ type: "line", x1: 100, y1: 100, x2: 300, y2: 100 }), + expect.objectContaining({ type: "line", x1: 50, y1: 400, x2: 80, y2: 350 }), + ]); + expect($("scene-annotation-label").textContent).toBe("Selected line:"); + expect(document.querySelectorAll("[data-annotation-handle=\"point\"]")).toHaveLength(2); + await saveAndReopen(); + expect(stored()).toHaveLength(2); + expect(canvasSvg().querySelectorAll(".scene-layer-annotations polyline")).toHaveLength(2); + }); + + it("ignores a click with the line tool instead of adding a zero-length line", async () => { + await openBlankScene(); + click("scene-tool-line"); + drag(canvasSvg(), [100, 100], [101, 100]); + expect(annotations()).toEqual([]); + expect($("scene-tool-line").getAttribute("aria-pressed")).toBe("true"); + }); + + it("moves a line's endpoints and the whole line, then deletes it (#431)", async () => { + await openBlankScene({ annotations: [{ id: "l", type: "line", x1: 100, y1: 100, x2: 300, y2: 100 }] }); + selectAnnotation(0); + + drag(document.querySelector("[data-point-index=\"1\"]"), [300, 100], [300, 250]); + expect(annotations()[0]).toMatchObject({ x1: 100, y1: 100, x2: 300, y2: 250 }); + drag(annotationNode(0), [200, 175], [210, 185]); + expect(annotations()[0]).toMatchObject({ x1: 110, y1: 110, x2: 310, y2: 260 }); + setField("scene-annotation-width", 50, "change"); + expect(annotations()[0].strokeWidth).toBe(20); + await saveAndReopen(); + expect(stored()[0]).toMatchObject({ x1: 110, y1: 110, x2: 310, y2: 260, strokeWidth: 20 }); + + selectAnnotation(0); + click("scene-annotation-delete"); + await saveAndReopen(); + expect(stored()).toEqual([]); + }); + + it("connects a pointer line to a label, keeps them together when the label moves, and after reopening (#432)", async () => { + await openBlankScene({ annotations: [{ id: "label", type: "text", text: "Ilium", x: 100, y: 100, fontSize: 20 }] }); + selectAnnotation(0); + + click("scene-annotation-pointer"); + expect($("scene-tool-hint").textContent).toMatch(/feature the label points to/); + clickAt(canvasSvg(), [400, 110]); + + // It starts on the label's right edge (the label is 60 x 20). + expect(annotations()[1]).toMatchObject({ type: "line", labelId: "label", x1: 160, y1: 110, x2: 400, y2: 110 }); + expect($("scene-annotation-label").textContent).toBe("Selected pointer line:"); + + drag(document.querySelector("[data-point-index=\"1\"]"), [400, 110], [420, 200]); + expect(annotations()[1]).toMatchObject({ x1: 160, y1: 110, x2: 420, y2: 200 }); + + selectAnnotation(0); + drag(annotationNode(0), [110, 105], [140, 125]); + // The label end moves with the label; the pointed-at end stays. + expect(annotations()[1]).toMatchObject({ x1: 190, y1: 130, x2: 420, y2: 200 }); + + await saveAndReopen(); + expect(stored()[1]).toMatchObject({ labelId: "label", x1: 190, y1: 130, x2: 420, y2: 200 }); + selectAnnotation(0); + key("ArrowLeft", { shiftKey: true }); + expect(annotations()[1]).toMatchObject({ x1: 180, y1: 130, x2: 420, y2: 200 }); + }); + + it("keeps a pointer line on its label's edge when the label grows, and deletes it with the label (#432)", async () => { + await openBlankScene({ + annotations: [ + { id: "label", type: "text", text: "Ilium", x: 100, y: 100, fontSize: 20 }, + { id: "p", type: "line", labelId: "label", x1: 160, y1: 110, x2: 400, y2: 110 }, + { id: "other", type: "line", x1: 0, y1: 0, x2: 50, y2: 50 }, + ], + }); + selectAnnotation(0); + + setField("scene-annotation-size", 40, "change"); + // The label is now 120 x 40, so its right edge is at x = 220. + expect(annotations()[1]).toMatchObject({ x1: 220, y1: 110, x2: 400, y2: 110 }); + + click("scene-annotation-delete"); + expect(annotations().map((a) => a.id)).toEqual(["other"]); + }); + + it("moves an imported label's imported pointer lines with it", async () => { + const source = { boneId: "ilium", annotationId: "a1" }; + await openBlankScene({ + annotations: [ + { id: "label", type: "text", text: "Ilium", x: 100, y: 100, fontSize: 20, source: { type: "legacy-label", ...source } }, + { id: "p", type: "arrow", x1: 400, y1: 300, x2: 150, y2: 115, source: { type: "legacy-pointer-line", ...source } }, + ], + }); + selectAnnotation(0); + drag(annotationNode(0), [110, 105], [120, 105]); + expect(annotations()[1]).toMatchObject({ x1: 400, y1: 300, x2: 160, y2: 115 }); + }); + + it("draws arrows pointing in different directions with a clear head, and keeps them (#433)", async () => { + await openBlankScene(); + for (const [from, to] of [[[100, 100], [300, 100]], [[300, 300], [100, 300]], [[500, 100], [500, 400]]]) { + click("scene-tool-arrow"); + drag(canvasSvg(), from, to); + } + + expect(annotations().map((a) => [a.type, a.x1, a.y1, a.x2, a.y2])).toEqual([ + ["arrow", 100, 100, 300, 100], + ["arrow", 300, 300, 100, 300], + ["arrow", 500, 100, 500, 400], + ]); + expect($("scene-annotation-label").textContent).toBe("Selected arrow:"); + const polylines = canvasSvg().querySelectorAll(".scene-layer-annotations polyline"); + expect([...polylines].every((line) => /url\(#scene-arrowhead-/.test(line.getAttribute("marker-end")))).toBe(true); + + drag(document.querySelector("[data-point-index=\"0\"]"), [500, 100], [450, 100]); + expect(annotations()[2]).toMatchObject({ x1: 450, y1: 100, x2: 500, y2: 400 }); + await saveAndReopen(); + expect(stored()[2]).toMatchObject({ type: "arrow", x1: 450, y1: 100, x2: 500, y2: 400 }); + selectAnnotation(1); + click("scene-annotation-delete"); + await saveAndReopen(); + expect(stored()).toHaveLength(2); + }); +}); + +describe("Highlight polygons (#434-#437)", () => { + function drawPolygon(points, finish = () => click("scene-polygon-finish")) { + click("scene-tool-polygon"); + for (const point of points) clickAt(canvasSvg(), point); + finish(); + } + + it("places corners one by one and finishes only when asked, with a transparent fill (#434)", async () => { + await openBlankScene(); + click("scene-tool-polygon"); + expect($("scene-polygon-finish").hidden).toBe(false); + clickAt(canvasSvg(), [100, 100]); + clickAt(canvasSvg(), [200, 100]); + expect($("scene-polygon-finish").disabled).toBe(true); + key("Enter"); + expect(annotations()).toEqual([]); + + clickAt(canvasSvg(), [150, 200]); + expect(document.querySelectorAll(".scene-draft-point")).toHaveLength(3); + expect($("scene-polygon-finish").disabled).toBe(false); + click("scene-polygon-finish"); + + expect(annotations()).toEqual([expect.objectContaining({ + type: "polygon", points: [[100, 100], [200, 100], [150, 200]], fillOpacity: 0.35, + })]); + const polygon = canvasSvg().querySelector(".scene-layer-annotations polygon"); + expect(Number(polygon.getAttribute("fill-opacity"))).toBeLessThan(1); + expect($("scene-annotation-label").textContent).toBe("Selected highlight:"); + }); + + it("creates triangles, quadrilaterals, and irregular shapes, finished by button, Enter, or the first corner (#434)", async () => { + await openBlankScene(); + drawPolygon([[0, 0], [100, 0], [50, 80]]); + drawPolygon([[200, 0], [300, 0], [300, 100], [200, 100]], () => key("Enter")); + drawPolygon([[400, 0], [480, 20], [520, 90], [470, 160], [420, 120]], () => clickAt(document.querySelector("[data-draft-start]"), [400, 0])); + + expect(annotations().map((a) => a.points.length)).toEqual([3, 4, 5]); + await saveAndReopen(); + expect(stored().map((a) => a.points.length)).toEqual([3, 4, 5]); + expect(canvasSvg().querySelectorAll(".scene-layer-annotations polygon")).toHaveLength(3); + }); + + it("ignores the repeated click of a double-click and finishes on it", async () => { + await openBlankScene(); + click("scene-tool-polygon"); + clickAt(canvasSvg(), [0, 0]); + clickAt(canvasSvg(), [100, 0]); + clickAt(canvasSvg(), [50, 80]); + clickAt(canvasSvg(), [50, 80]); + mouse("dblclick", canvasSvg(), [50, 80]); + expect(annotations()[0].points).toEqual([[0, 0], [100, 0], [50, 80]]); + }); + + it("removes the last corner with Backspace and cancels with Escape", async () => { + await openBlankScene(); + click("scene-tool-polygon"); + clickAt(canvasSvg(), [0, 0]); + clickAt(canvasSvg(), [100, 0]); + key("Backspace"); + expect(document.querySelectorAll(".scene-draft-point")).toHaveLength(1); + key("Escape"); + expect(document.querySelectorAll(".scene-draft-point")).toHaveLength(0); + expect(annotations()).toEqual([]); + expect(scenesModule.hasUnsavedChanges()).toBe(false); + }); + + it("moves corners and the whole polygon and keeps the shape (#435)", async () => { + await openBlankScene({ annotations: [{ id: "p", type: "polygon", points: [[100, 100], [200, 100], [200, 200], [100, 200]] }] }); + selectAnnotation(0); + expect(document.querySelectorAll("[data-annotation-handle=\"point\"]")).toHaveLength(4); + + drag(document.querySelector("[data-point-index=\"2\"]"), [200, 200], [260, 240]); + drag(annotationNode(0), [150, 150], [160, 140]); + + expect(annotations()[0].points).toEqual([[110, 90], [210, 90], [270, 230], [110, 190]]); + await saveAndReopen(); + expect(stored()[0].points).toEqual([[110, 90], [210, 90], [270, 230], [110, 190]]); + }); + + it("edits an imported region in its own point format", async () => { + await openBlankScene({ + annotations: [{ id: "r", type: "polygon", points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 0, y: 40 }], fill: "#00ff00", opacity: 0.4 }], + }); + selectAnnotation(0); + drag(document.querySelector("[data-point-index=\"1\"]"), [40, 0], [60, 10]); + expect(annotations()[0].points).toEqual([{ x: 0, y: 0 }, { x: 60, y: 10 }, { x: 0, y: 40 }]); + expect($("scene-annotation-opacity").value).toBe("0.4"); + }); + + it("changes a highlight's color and opacity, visible before saving and kept after (#436)", async () => { + await openBlankScene({ + annotations: [{ id: "p", type: "polygon", points: [[0, 0], [100, 0], [0, 100]], fill: "#ffd700", fillOpacity: 0.35 }], + }); + selectAnnotation(0); + expect($("scene-annotation-opacity-field").hidden).toBe(false); + expect($("scene-annotation-size-field").hidden).toBe(true); + + setField("scene-annotation-color", "#3366ff", "input"); + setField("scene-annotation-opacity", "0.6", "input"); + + const polygon = canvasSvg().querySelector(".scene-layer-annotations polygon"); + expect(polygon.getAttribute("fill")).toBe("#3366ff"); + expect(polygon.getAttribute("fill-opacity")).toBe("0.6"); + await saveAndReopen(); + expect(stored()[0]).toMatchObject({ fill: "#3366ff", stroke: "#3366ff", fillOpacity: 0.6 }); + selectAnnotation(0); + expect($("scene-annotation-color").value).toBe("#3366ff"); + expect($("scene-annotation-opacity").value).toBe("0.6"); + }); + + it("never makes a highlight fully opaque, and replaces an imported region's whole-shape opacity (#436)", async () => { + await openBlankScene({ annotations: [{ id: "r", type: "polygon", points: [[0, 0], [10, 0], [0, 10]], fill: "#00ff00", opacity: 0.4 }] }); + selectAnnotation(0); + setField("scene-annotation-opacity", "1", "input"); + expect(annotations()[0].fillOpacity).toBe(0.8); + expect(annotations()[0].opacity).toBeUndefined(); + }); + + it("colors labels and lines too", async () => { + await openBlankScene({ + annotations: [ + { id: "a", type: "text", text: "Ilium", x: 0, y: 0 }, + { id: "l", type: "arrow", x1: 0, y1: 50, x2: 100, y2: 50 }, + ], + }); + selectAnnotation(0); + setField("scene-annotation-color", "#aa0000", "input"); + selectAnnotation(1); + setField("scene-annotation-color", "#00aa00", "input"); + expect(annotations()[0].color).toBe("#aa0000"); + expect(annotations()[1].stroke).toBe("#00aa00"); + }); + + it("deletes one polygon from a scene with several polygons and annotations (#437)", async () => { + const square = (id, x) => ({ id, type: "polygon", points: [[x, 0], [x + 50, 0], [x + 50, 50], [x, 50]] }); + await openBlankScene({ + annotations: [square("p1", 0), { id: "t", type: "text", text: "Ilium", x: 0, y: 100 }, square("p2", 100), square("p3", 200)], + }); + selectAnnotation(2); + click("scene-annotation-delete"); + expect(annotations().map((a) => a.id)).toEqual(["p1", "t", "p3"]); + await saveAndReopen(); + expect(stored().map((a) => a.id)).toEqual(["p1", "t", "p3"]); + }); +}); + +describe("Annotation layers (#438)", () => { + const overlapping = [ + { id: "poly", type: "polygon", points: [[0, 0], [200, 0], [200, 200], [0, 200]] }, + { id: "line", type: "line", x1: 0, y1: 100, x2: 200, y2: 100 }, + { id: "text", type: "text", text: "Ilium", x: 50, y: 90 }, + ]; + const images = [ + { id: "img-a", src: "/images/a.png", x: 0, y: 0, width: 100, height: 100 }, + { id: "img-b", src: "/images/b.png", x: 50, y: 50, width: 100, height: 100 }, + ]; + const drawnOrder = () => [...canvasSvg().querySelectorAll(".scene-layer-annotations > :not(.scene-annotation-hit)")] + .map((node) => annotations()[Number(node.dataset.sceneAnnotationIndex)].id); + + it("changes which overlapping annotation is on top, right away, without touching image order", async () => { + await openBlankScene({ images: images.map((image) => ({ ...image })), annotations: overlapping.map((a) => ({ ...a })) }); + selectAnnotation(0); + expect($("scene-annotation-back").disabled).toBe(true); + expect($("scene-annotation-backward").disabled).toBe(true); + + click("scene-annotation-forward"); + expect(drawnOrder()).toEqual(["line", "poly", "text"]); + click("scene-annotation-front"); + expect(drawnOrder()).toEqual(["line", "text", "poly"]); + expect($("scene-annotation-front").disabled).toBe(true); + expect($("scene-annotation-toolbar").hidden).toBe(false); + + selectAnnotation(1); + click("scene-annotation-back"); + expect(drawnOrder()).toEqual(["text", "line", "poly"]); + click("scene-annotation-forward"); + expect(drawnOrder()).toEqual(["line", "text", "poly"]); + expect(scenesModule.getActiveScene().images.map((image) => image.id)).toEqual(["img-a", "img-b"]); + + await saveAndReopen(); + expect(stored().map((a) => a.id)).toEqual(["line", "text", "poly"]); + expect(drawnOrder()).toEqual(["line", "text", "poly"]); + expect(backend.scenes.get("scene-1").images.map((image) => image.id)).toEqual(["img-a", "img-b"]); + }); +}); + +describe("Annotations linked to an image", () => { + it("stores a moved imported label relative to its image, so it still follows the image", async () => { + const image = { id: "img", src: "/images/a.png", x: 0, y: 0, width: 200, height: 100 }; + await openBlankScene({ + images: [image], + annotations: [{ + id: "a", type: "text", text: "Ilium", x: 20, y: 20, fontSize: 10, + sourceImageId: "img", sourceImageFrame: { x: 0, y: 0, width: 100, height: 50 }, + }], + }); + // The image is twice its imported size, so the label is drawn at (40, 40), 20px tall. + selectAnnotation(0); + drag(annotationNode(0), [50, 50], [70, 60]); + + expect(annotations()[0]).toMatchObject({ x: 30, y: 25, fontSize: 10, sourceImageFrame: { x: 0, y: 0, width: 100, height: 50 } }); + const text = canvasSvg().querySelector(".scene-layer-annotations text"); + expect(Number(text.getAttribute("x"))).toBeCloseTo(60); + expect(Number(text.getAttribute("y"))).toBeCloseTo(50); + }); +}); + +describe("Annotation selection", () => { + it("selects either an image or an annotation, never both", async () => { + await openBlankScene({ + images: [{ id: "img", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100 }], + annotations: [{ id: "a", type: "text", text: "Ilium", x: 0, y: 0 }], + }); + selectAnnotation(0); + clickAt(document.querySelector("[data-scene-image-index=\"0\"]"), [350, 350]); + expect($("scene-image-toolbar").hidden).toBe(false); + expect($("scene-annotation-toolbar").hidden).toBe(true); + + selectAnnotation(0); + expect($("scene-image-toolbar").hidden).toBe(true); + expect($("scene-annotation-toolbar").hidden).toBe(false); + + clickAt(canvasSvg(), [900, 500]); + expect($("scene-annotation-toolbar").hidden).toBe(true); + }); + + it("clears the selection and tool when another scene is opened", async () => { + backend.seed({ id: "scene-2", name: "Another", annotations: [] }); + await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 0, y: 0 }] }); + selectAnnotation(0); + [...document.querySelectorAll(".scene-list-item")].find((b) => b.textContent.includes("Another")).click(); + await waitFor(() => expect($("scene-title").textContent).toBe("Another")); + expect($("scene-annotation-toolbar").hidden).toBe(true); + expect($("scene-canvas-empty").hidden).toBe(false); + }); +}); diff --git a/templates/tests/sceneAnnotations.test.js b/templates/tests/sceneAnnotations.test.js new file mode 100644 index 0000000..8e8799d --- /dev/null +++ b/templates/tests/sceneAnnotations.test.js @@ -0,0 +1,211 @@ +// Geometry for editing annotations (Issue #426: #427-#438), and storing an +// edit made on a drawn annotation back into its own coordinates. +import { displayedAnnotation, renderScene, storeAnnotationGeometry, textBox } from "../js/sceneCanvas.js"; +import { + anchorOnBox, + clampHighlightOpacity, + isPointerLineFor, + labelEndIndex, + moveAnnotationPoint, + reanchorPointerLine, + resizeText, + toColorInputValue, + translateAnnotation, +} from "../js/sceneAnnotations.js"; + +// Stored coordinates are rounded to 0.01, so a point drawn through a 2x image +// lands within a few hundredths of a pixel of where it was put. +const expectPoint = (actual, [x, y]) => { + expect(actual[0]).toBeCloseTo(x, 1); + expect(actual[1]).toBeCloseTo(y, 1); +}; + +describe("textBox", () => { + it("measures one line as fontSize tall, as before multi-line labels", () => { + expect(textBox({ text: "Ilium", x: 10, y: 20, fontSize: 20 })).toMatchObject({ + x: 10, y: 20, width: 5 * 20 * 0.6, height: 20, lines: ["Ilium"], + }); + }); + + it("sizes several lines by the longest line and the line count", () => { + const box = textBox({ text: "Iliac\nCrest of ilium", x: 0, y: 0, fontSize: 10 }); + expect(box.width).toBeCloseTo(14 * 10 * 0.6); + expect(box.height).toBeCloseTo(10 + 10 * 1.2); + }); +}); + +describe("editing displayed geometry", () => { + it("moves text, lines, and polygons without changing the original", () => { + const text = { type: "text", text: "A", x: 1, y: 2 }; + expect(translateAnnotation(text, 5, -2)).toMatchObject({ x: 6, y: 0 }); + expect(text).toMatchObject({ x: 1, y: 2 }); + + const line = { type: "line", x1: 0, y1: 0, x2: 10, y2: 10 }; + expect(translateAnnotation(line, 1, 2)).toMatchObject({ x1: 1, y1: 2, x2: 11, y2: 12 }); + + const polygon = { type: "polygon", points: [[0, 0], [10, 0], [0, 10]] }; + expect(translateAnnotation(polygon, 3, 4).points).toEqual([[3, 4], [13, 4], [3, 14]]); + }); + + it("moves one line end or polygon corner", () => { + const arrow = { type: "arrow", x1: 0, y1: 0, x2: 10, y2: 10 }; + expect(moveAnnotationPoint(arrow, 1, 5, 0)).toMatchObject({ x1: 0, y1: 0, x2: 15, y2: 10 }); + const polygon = { type: "polygon", points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 10 }] }; + expect(moveAnnotationPoint(polygon, 2, 0, 5).points).toEqual([[0, 0], [10, 0], [0, 15]]); + expect(moveAnnotationPoint(polygon, 7, 1, 1)).toBe(polygon); + }); + + it("keeps resized text readable", () => { + expect(resizeText({ type: "text", text: "A", fontSize: 18 }, 2).fontSize).toBe(8); + expect(resizeText({ type: "text", text: "A", fontSize: 18 }, 999).fontSize).toBe(200); + expect(resizeText({ type: "text", text: "A", fontSize: 18 }, 30).fontSize).toBe(30); + }); + + it("keeps a highlight see-through", () => { + expect(clampHighlightOpacity(1)).toBe(0.8); + expect(clampHighlightOpacity(0)).toBe(0.1); + }); + + it("turns colors into values a color input can show", () => { + expect(toColorInputValue("#F97316", "#000000")).toBe("#f97316"); + expect(toColorInputValue("#abc", "#000000")).toBe("#aabbcc"); + expect(toColorInputValue("rgba(255, 0, 0, 0.5)", "#000000")).toBe("#000000"); + expect(toColorInputValue(undefined, "#123456")).toBe("#123456"); + }); +}); + +describe("pointer lines", () => { + const box = { x: 100, y: 100, width: 60, height: 20 }; + + it("anchors on the label's edge nearest the target", () => { + expect(anchorOnBox(box, [300, 110])).toEqual([160, 110]); + expect(anchorOnBox(box, [130, 0])).toEqual([130, 100]); + expect(anchorOnBox(box, [0, 0])).toEqual([100, 100]); + // From inside the box: the middle of the nearest edge. + expect(anchorOnBox(box, [105, 110])).toEqual([100, 110]); + }); + + it("recognizes created and imported pointer lines", () => { + const label = { id: "label-1", type: "text", source: { type: "legacy-label", boneId: "ilium", annotationId: "a1" }, sourceImageId: "img" }; + expect(isPointerLineFor({ type: "line", labelId: "label-1" }, label)).toBe(true); + expect(isPointerLineFor({ type: "line", labelId: "other" }, label)).toBe(false); + const imported = { + type: "arrow", + source: { type: "legacy-pointer-line", boneId: "ilium", annotationId: "a1" }, + sourceImageId: "img", + }; + expect(isPointerLineFor(imported, label)).toBe(true); + expect(isPointerLineFor({ ...imported, sourceImageId: "other-copy" }, label)).toBe(false); + expect(isPointerLineFor({ type: "polygon", labelId: "label-1" }, label)).toBe(false); + }); + + it("finds the label end of a line: the first point of a created one, otherwise the nearer end", () => { + expect(labelEndIndex({ type: "line", labelId: "x", x1: 500, y1: 500, x2: 130, y2: 110 }, box)).toBe(0); + expect(labelEndIndex({ type: "line", x1: 500, y1: 500, x2: 130, y2: 110 }, box)).toBe(1); + }); + + it("moves the label end back onto a resized label's edge", () => { + const label = { type: "text", text: "Ilium", x: 100, y: 100, fontSize: 20 }; // 60 x 20 + const line = { type: "line", labelId: "x", x1: 160, y1: 110, x2: 300, y2: 110 }; + const bigger = { ...label, fontSize: 40 }; // 120 x 40 + expect(reanchorPointerLine(line, bigger)).toMatchObject({ x1: 220, y1: 110, x2: 300, y2: 110 }); + }); +}); + +describe("storeAnnotationGeometry", () => { + it("stores an unlinked annotation's edit as-is, in its own format", () => { + const scene = { images: [], annotations: [] }; + const line = { type: "line", x1: 0, y1: 0, x2: 10, y2: 0 }; + storeAnnotationGeometry(line, moveAnnotationPoint(line, 1, 5, 5), scene); + expect(line).toEqual({ type: "line", x1: 0, y1: 0, x2: 15, y2: 5 }); + + const region = { type: "polygon", points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 10 }] }; + storeAnnotationGeometry(region, translateAnnotation(region, 1, 1), scene); + expect(region.points).toEqual([{ x: 1, y: 1 }, { x: 11, y: 1 }, { x: 1, y: 11 }]); + }); + + // An image moved, scaled 2x, flipped, and rotated since the annotations + // were linked to it: an edit made where they are drawn must be stored in + // the original frame and drawn exactly where the user put it. + const image = { id: "img", src: "/a.png", x: 300, y: 200, width: 200, height: 100, rotation: 30, flipX: true }; + const frame = { x: 0, y: 0, width: 100, height: 50 }; + const linked = (annotation) => ({ ...annotation, sourceImageId: "img", sourceImageFrame: { ...frame } }); + + it("maps a moved linked line back into its frame", () => { + const line = linked({ type: "line", x1: 10, y1: 10, x2: 40, y2: 20, strokeWidth: 2 }); + const scene = { images: [image], annotations: [line] }; + const shown = displayedAnnotation(line, scene); + const moved = translateAnnotation(shown, 25, -15); + + storeAnnotationGeometry(line, moved, scene); + + expect(line.sourceImageFrame).toEqual(frame); + expect(Object.keys(line)).toEqual(expect.arrayContaining(["x1", "y1", "x2", "y2"])); + expect(line.points).toBeUndefined(); + const redrawn = displayedAnnotation(line, scene).points; + expectPoint(redrawn[0], moved.points[0]); + expectPoint(redrawn[1], moved.points[1]); + expect(line.strokeWidth).toBeCloseTo(2); + }); + + it("maps a resized linked label back into its frame and keeps its drawn position", () => { + const label = linked({ type: "text", text: "Ilium", x: 20, y: 10, fontSize: 10 }); + const scene = { images: [image], annotations: [label] }; + const resized = resizeText(translateAnnotation(displayedAnnotation(label, scene), 12, 7), 30); + + storeAnnotationGeometry(label, resized, scene); + + const redrawn = displayedAnnotation(label, scene); + expect(redrawn.fontSize).toBeCloseTo(30); + expect(redrawn.x).toBeCloseTo(resized.x, 1); + expect(redrawn.y).toBeCloseTo(resized.y, 1); + expect(label.fontSize).toBeCloseTo(15); + }); +}); + +describe("renderScene annotation editing support", () => { + const scene = { + images: [], + annotations: [ + { type: "text", text: "Two\nlines", x: 10, y: 10, fontSize: 20 }, + { type: "line", x1: 0, y1: 0, x2: 50, y2: 50 }, + { type: "polygon", points: [[0, 0], [40, 0], [0, 40]], fill: "#ff0000", fillOpacity: 0.3 }, + ], + }; + + it("tags each annotation with its index and adds easy click targets", () => { + const { svg } = renderScene(scene); + const tagged = [...svg.querySelectorAll("[data-scene-annotation-index]")].map((node) => node.dataset.sceneAnnotationIndex); + expect(new Set(tagged)).toEqual(new Set(["0", "1", "2"])); + expect(svg.querySelector("path.scene-annotation-hit-line").getAttribute("d")).toBe("M 0 0 L 50 50"); + expect(svg.querySelector("rect.scene-annotation-hit")).not.toBeNull(); + }); + + it("draws each line of a multi-line label", () => { + const { svg } = renderScene(scene); + const lines = [...svg.querySelectorAll("text tspan")].map((tspan) => tspan.textContent); + expect(lines).toEqual(["Two", "lines"]); + }); + + it("draws a highlight's fill opacity", () => { + const { svg } = renderScene(scene); + expect(svg.querySelector("polygon").getAttribute("fill-opacity")).toBe("0.3"); + }); + + it("shows a resize handle on a selected label and a handle on each point of a selected shape", () => { + expect(renderScene(scene, { selectedAnnotationIndex: 0 }).svg + .querySelectorAll("[data-annotation-handle=\"resize\"]")).toHaveLength(1); + expect(renderScene(scene, { selectedAnnotationIndex: 1 }).svg + .querySelectorAll("[data-annotation-handle=\"point\"]")).toHaveLength(2); + expect(renderScene(scene, { selectedAnnotationIndex: 2 }).svg + .querySelectorAll("[data-annotation-handle=\"point\"]")).toHaveLength(3); + expect(renderScene(scene).svg.querySelectorAll("[data-annotation-handle]")).toHaveLength(0); + }); + + it("draws a shape that is still being drawn", () => { + const { svg } = renderScene(scene, { draft: { type: "polygon", points: [[0, 0], [10, 0], [10, 10]] } }); + expect(svg.querySelectorAll(".scene-draft-point")).toHaveLength(3); + expect(svg.querySelector("[data-draft-start]")).not.toBeNull(); + expect(svg.querySelector(".scene-draft-closing")).not.toBeNull(); + }); +}); From 0d602e9d4cdc3d36b70891c1dc05e657bb66533b Mon Sep 17 00:00:00 2001 From: mayokunl <153923029+mayokunl@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:49:17 -0500 Subject: [PATCH 2/3] Strip scripts from the test page with the DOM instead of a regex CodeQL (js/incomplete-multi-character-sanitization) flagged the regex that removed