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