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..8ab3544 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,10 @@ function setActiveScene(scene) { state.missing = false; state.saveAlert = null; deselectImage(); + deselectAnnotation(); + endLineDraw(); + state.tool = null; + state.draft = null; } /** The parts of a scene a save stores. */ @@ -219,6 +250,9 @@ let dom = null; // same element regardless of how far the mouse moved in between, so without // this a move/resize drag would immediately deselect the image it just moved. let suppressNextClick = false; +// Removes the document-level listeners of a line or arrow being drawn, or +// null when none is (Issues #431/#433). Called through endLineDraw. +let stopLineDraw = null; // Issues #411/#412: importing an image into a scene. The image is read as a // base64 data: URL - that same string is both the immediate preview `src` and @@ -478,7 +512,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 +524,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 +1272,567 @@ 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; + endLineDraw(); + 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; + endLineDraw(); + 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(); +} + +/** + * Stops a line or arrow drag that is still in progress, so its listeners + * never see a draft that was cleared (Escape, a tool change, another scene). + * The mouse button is still down, so the click that follows its release is + * ignored too. Safe to call when no drag is active. + */ +function endLineDraw() { + if (!stopLineDraw) return; + stopLineDraw(); + suppressNextClick = true; +} + +/** Issues #431/#433: draws a line or arrow by dragging from its start to its end. */ +function startLineDraw(event, svg) { + endLineDraw(); + 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 detach() { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + if (stopLineDraw === detach) stopLineDraw = null; + } + + function onMouseMove(moveEvent) { + if (!state.draft) return; + state.draft.points[1] = [ + round2(start[0] + (moveEvent.clientX - startClientX) * scale.x), + round2(start[1] + (moveEvent.clientY - startClientY) * scale.y), + ]; + renderCanvas(); + } + + function onMouseUp() { + detach(); + 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); + stopLineDraw = detach; + 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 +2215,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 +2289,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 +2313,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 +2404,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..a7817f3 --- /dev/null +++ b/templates/tests/sceneAnnotationEditor.test.js @@ -0,0 +1,726 @@ +// 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"); +// The page's markup without its scripts. Parsed and stripped as a DOM rather +// than with a regex, which can't reliably remove every