diff --git a/templates/boneset.html b/templates/boneset.html
index cf117ae..7ebbe0b 100644
--- a/templates/boneset.html
+++ b/templates/boneset.html
@@ -250,6 +250,8 @@
+
+
@@ -328,6 +330,7 @@
+
diff --git a/templates/js/sceneCanvas.js b/templates/js/sceneCanvas.js
index 0cecd8a..2ec2cea 100644
--- a/templates/js/sceneCanvas.js
+++ b/templates/js/sceneCanvas.js
@@ -555,6 +555,111 @@ function renderAnnotation(annotation, defs) {
}
}
+// Issue #441: finding every object under a point, so overlapping objects can
+// be reached and not only the one drawn on top. This repeats the browser's
+// hit testing in scene geometry, matching what renderScene draws and what
+// its hit areas make clickable.
+
+/** Whether a point is inside a polygon (even-odd rule). */
+function pointInPolygon([px, py], points) {
+ let inside = false;
+ for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
+ const [xi, yi] = points[i];
+ const [xj, yj] = points[j];
+ if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
+ }
+ return inside;
+}
+
+function distanceToSegment([px, py], [ax, ay], [bx, by]) {
+ const dx = bx - ax;
+ const dy = by - ay;
+ const lengthSquared = dx * dx + dy * dy;
+ const t = lengthSquared ? Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)) : 0;
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
+}
+
+/** Whether a point is within `tolerance` of a polyline (or a closed polygon's outline). */
+function isNearPolyline(point, points, tolerance, closed = false) {
+ const segments = closed ? points.length : points.length - 1;
+ for (let i = 0; i < segments; i += 1) {
+ if (distanceToSegment(point, points[i], points[(i + 1) % points.length]) <= tolerance) return true;
+ }
+ return false;
+}
+
+/** The visible (cropped) area of an image as drawn - flipped and rotated - or null if it isn't drawn. */
+function imageHitArea(image) {
+ if (!image || typeof image.src !== "string" || !SAFE_IMAGE_SRC.test(image.src)) return null;
+ if (!isValidBox(image)) return null;
+ const crop = cropRectFor(image);
+ const corners = [
+ [image.x + crop.x, image.y + crop.y],
+ [image.x + crop.x + crop.width, image.y + crop.y],
+ [image.x + crop.x + crop.width, image.y + crop.y + crop.height],
+ [image.x + crop.x, image.y + crop.y + crop.height],
+ ];
+ return corners.map(imageTransform(image, image).toScreen);
+}
+
+/** Whether a drawn annotation (displayed geometry) covers a point. */
+function annotationHit(annotation, point) {
+ switch (annotation.type) {
+ case "text": {
+ if (typeof annotation.text !== "string" || !isNumber(annotation.x) || !isNumber(annotation.y)) return false;
+ const box = textBox(annotation);
+ const [x, y] = point;
+ return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
+ }
+ case "line":
+ case "arrow": {
+ const points = linePoints(annotation);
+ const width = isNumber(annotation.strokeWidth) ? annotation.strokeWidth : 2;
+ return Boolean(points) && isNearPolyline(point, points, Math.max(LINE_HIT_WIDTH, width + 8) / 2);
+ }
+ case "polygon": {
+ const points = toPoints(annotation.points);
+ if (!points || points.length < 3) return false;
+ const width = isNumber(annotation.strokeWidth) ? annotation.strokeWidth : 1.5;
+ return pointInPolygon(point, points) || isNearPolyline(point, points, width / 2, true);
+ }
+ default:
+ return false;
+ }
+}
+
+/**
+ * Every object drawn at a scene point, topmost first, as { kind, index }:
+ * annotations (each later one before the ones under it), then images. Objects
+ * the canvas can't draw are left out, and a colored region clipped to its
+ * image's crop only counts inside the visible crop area.
+ * @param {{ images: object[], annotations: object[] }} scene
+ * @param {[number, number]} point - Scene pixels.
+ * @returns {{ kind: "image" | "annotation", index: number }[]}
+ */
+export function objectsAt(scene, point) {
+ const hits = [];
+ const images = scene.images || [];
+ const annotations = scene.annotations || [];
+ for (let index = annotations.length - 1; index >= 0; index -= 1) {
+ const annotation = annotations[index];
+ if (!annotation) continue;
+ const transform = linkedTransform(annotation, scene);
+ if (!annotationHit(transform ? mapLinkedAnnotation(annotation, transform) : annotation, point)) continue;
+ if (transform && clipsToCrop(annotation)) {
+ const image = images.find((candidate) => candidate && candidate.id === annotation.sourceImageId);
+ const visible = visibleCropPolygon(image, transform);
+ if (visible && !pointInPolygon(point, visible)) continue;
+ }
+ hits.push({ kind: "annotation", index });
+ }
+ for (let index = images.length - 1; index >= 0; index -= 1) {
+ const area = imageHitArea(images[index]);
+ if (area && pointInPolygon(point, area)) hits.push({ kind: "image", index });
+ }
+ return hits;
+}
+
/**
* Renders a scene's images and annotations into an SVG element. Optionally draws a
* non-interactive selection outline around one image, identified by its index in
diff --git a/templates/js/scenes.js b/templates/js/scenes.js
index fa66be9..9bfd97f 100644
--- a/templates/js/scenes.js
+++ b/templates/js/scenes.js
@@ -1,5 +1,5 @@
import { OPEN_SCENE_EVENT, SHOW_IN_VIEWER_EVENT, sceneSources, readableName } from "./sceneLinks.js";
-import { renderScene, displayedAnnotation, storeAnnotationGeometry, textBox } from "./sceneCanvas.js";
+import { renderScene, displayedAnnotation, objectsAt, storeAnnotationGeometry, textBox } from "./sceneCanvas.js";
import {
DEFAULT_HIGHLIGHT_COLOR,
DEFAULT_HIGHLIGHT_OPACITY,
@@ -213,25 +213,64 @@ const state = {
openRequest: 0,
fitToWidth: true,
busy: false,
- selectedImageIndex: null,
+ // Issue #440: the one selected object, `{ kind: "image" | "annotation",
+ // index }` (its index in `active.images` or `active.annotations`), or null.
+ selection: null,
+ // Issue #441: how many objects were under the click that made the
+ // selection, while that's more than one (for the "click again" hint).
+ overlapCount: 0,
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,
+ // Issue #426: the annotation tool in use ("text" | "line" | "arrow" |
+ // "polygon" | "pointer"), and the shape being drawn with it.
tool: null,
draft: null,
+ // Issue #443: undo and redo for the open scene. `present` is a copy of
+ // the working copy's content as of the last recorded change; `past` and
+ // `future` hold the copies before and after it. `lastKey` names the kind
+ // of the last change, so a run of small edits of one kind (typing a
+ // label, nudging with the arrow keys) is undone as one step.
+ history: { past: [], present: null, future: [], lastKey: null },
boneOptions: null,
importRequest: 0,
};
-function deselectImage() {
- state.selectedImageIndex = null;
+/** The selected image's index, or null when no image is selected. */
+function selectedImageIndex() {
+ return state.selection && state.selection.kind === "image" ? state.selection.index : null;
+}
+
+/** The selected annotation's index, or null when no annotation is selected. */
+function selectedAnnotationIndex() {
+ return state.selection && state.selection.kind === "annotation" ? state.selection.index : null;
+}
+
+/**
+ * Selects one object, replacing any other selection. Crop mode only lasts
+ * while the image it was started on stays selected.
+ * @param {"image"|"annotation"} kind
+ * @param {number} index
+ */
+function select(kind, index) {
+ const isSame = state.selection && state.selection.kind === kind && state.selection.index === index;
+ if (!isSame) {
+ state.cropMode = false;
+ // Edits of another object are never grouped with these.
+ state.history.lastKey = null;
+ }
+ state.selection = { kind, index };
+ state.overlapCount = 0;
+}
+
+function deselect() {
+ state.selection = null;
state.cropMode = false;
+ state.overlapCount = 0;
+ state.history.lastKey = null;
}
-function deselectAnnotation() {
- state.selectedAnnotationIndex = null;
+/** Whether `object` ({ kind, index }) is the selected object. */
+function isSelected(object) {
+ return Boolean(state.selection) && object.kind === state.selection.kind && object.index === state.selection.index;
}
const clone = (value) => JSON.parse(JSON.stringify(value));
@@ -246,13 +285,14 @@ function setActiveScene(scene) {
state.saved = scene ? clone(scene) : null;
state.revision = 0;
state.savedRevision = 0;
+ // Undo never reaches into another scene, or a version that was reloaded.
+ state.history = { past: [], present: scene ? contentSnapshot(scene) : null, future: [], lastKey: null };
state.activeToken += 1;
state.saveFailed = false;
state.conflictUpdatedAt = null;
state.missing = false;
state.saveAlert = null;
- deselectImage();
- deselectAnnotation();
+ deselect();
endLineDraw();
state.tool = null;
state.draft = null;
@@ -384,6 +424,8 @@ function updateControls() {
dom.duplicateButton.disabled = locked || !state.active;
updatePublishControls(locked);
dom.saveButton.disabled = locked || !hasUnsavedChanges();
+ dom.undoButton.disabled = !canUndo();
+ dom.redoButton.disabled = !canRedo();
if (dom.importToggle) {
dom.importToggle.disabled = state.busy || !state.active;
dom.importSubmit.disabled = state.busy || !state.boneOptions;
@@ -424,15 +466,145 @@ function updatePublishControls(locked) {
}
-/** Records a change to the working copy (Issue #451). Callers re-render. */
-function markChanged() {
+const MAX_UNDO_STEPS = 100;
+// Revisions only ever count up, even after an undo, so a revision number
+// always names one version of the content: undoing back to the saved version
+// shows it as saved again, and a new edit after an undo never looks saved.
+let lastRevision = 0;
+
+/** A copy of an image for the undo history, sharing its `src` (possibly megabytes of base64, never changed in place). */
+function copyImage(image) {
+ const { src, ...rest } = image;
+ const copy = clone(rest);
+ if (src !== undefined) copy.src = src;
+ return copy;
+}
+
+/** A copy of the content a save stores, with its revision, for the undo history (Issue #443). */
+function contentSnapshot(scene) {
+ const snapshot = {
+ images: (scene.images || []).map(copyImage),
+ annotations: clone(scene.annotations || []),
+ revision: state.revision,
+ };
+ if (Array.isArray(scene.importLog)) snapshot.importLog = clone(scene.importLog);
+ return snapshot;
+}
+
+/**
+ * Records a change to the working copy (Issue #451), as one undo step
+ * (Issue #443). Callers re-render.
+ * @param {{ group?: string }} [options] - group names a kind of small,
+ * repeated edit; consecutive changes with the same group undo as one step.
+ */
+function markChanged({ group = null } = {}) {
if (!state.active) return;
const wasSaved = !hasUnsavedChanges();
- state.revision += 1;
+ lastRevision += 1;
+ state.revision = lastRevision;
+ const history = state.history;
+ const continuesGroup = group !== null && group === history.lastKey;
+ if (!continuesGroup && history.present) {
+ history.past.push(history.present);
+ if (history.past.length > MAX_UNDO_STEPS) history.past.shift();
+ }
+ history.present = contentSnapshot(state.active);
+ history.future = [];
+ history.lastKey = group;
+ // The overlap hint described the click before this edit.
+ state.overlapCount = 0;
if (wasSaved) announce("Unsaved changes.");
updateControls();
}
+function canUndo() {
+ return Boolean(state.active) && !state.busy && !activeDrag && state.history.past.length > 0;
+}
+
+function canRedo() {
+ return Boolean(state.active) && !state.busy && !activeDrag && state.history.future.length > 0;
+}
+
+/** Issue #443: puts the working copy back the way it was before the last recorded change. */
+function undo() {
+ if (!canUndo()) return;
+ const history = state.history;
+ history.future.push(history.present);
+ history.present = history.past.pop();
+ showHistoryStep("Undone");
+}
+
+/** Issue #443: reapplies the last undone change. */
+function redo() {
+ if (!canRedo()) return;
+ const history = state.history;
+ history.past.push(history.present);
+ history.present = history.future.pop();
+ showHistoryStep("Redone");
+}
+
+/**
+ * Makes the working copy match `history.present`. A drawing tool in use is
+ * put away first. The selected object stays selected if it still exists.
+ * @param {string} verb - "Undone" or "Redone", for the announcement.
+ */
+function showHistoryStep(verb) {
+ const scene = state.active;
+ const snapshot = state.history.present;
+ const selected = state.selection
+ ? (state.selection.kind === "image" ? scene.images : scene.annotations)[state.selection.index]
+ : null;
+ const selectedKind = state.selection && state.selection.kind;
+ endLineDraw();
+ state.tool = null;
+ state.draft = null;
+
+ scene.images = snapshot.images.map(copyImage);
+ scene.annotations = clone(snapshot.annotations);
+ if (snapshot.importLog) scene.importLog = clone(snapshot.importLog);
+ else delete scene.importLog;
+ state.revision = snapshot.revision;
+
+ const objects = selectedKind === "image" ? scene.images : scene.annotations;
+ const index = selected && selected.id !== undefined ? objects.findIndex((object) => object && object.id === selected.id) : -1;
+ if (index === -1) deselect();
+ else {
+ state.selection = { kind: selectedKind, index };
+ state.cropMode = false;
+ state.overlapCount = 0;
+ }
+ state.history.lastKey = null;
+ updateControls();
+ renderWorkspace();
+ const remaining = verb === "Undone" ? state.history.past.length : state.history.future.length;
+ announce(`${verb}. ${remaining === 0
+ ? `Nothing more to ${verb === "Undone" ? "undo" : "redo"}.`
+ : `${plural(remaining, "more change")} can be ${verb === "Undone" ? "undone" : "redone"}.`}`);
+}
+
+/** Whether a key press belongs to a text field, which has its own undo. */
+function isTextEntry(target) {
+ if (!target || typeof target.closest !== "function") return false;
+ if (target.closest("textarea, [contenteditable=\"true\"]")) return true;
+ const input = target.closest("input");
+ return Boolean(input) && ["text", "search", "number", "email", "url", "tel", "password"].includes(input.type);
+}
+
+/** Ctrl/Cmd+Z undoes; Ctrl/Cmd+Shift+Z or Ctrl+Y redoes, while the editor is open. */
+function handleUndoKey(event) {
+ // isConnected: an editor whose page was replaced (as tests do) stays out of the way.
+ if (!dom || !dom.editorView.isConnected || dom.editorView.hidden || !state.active) return;
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
+ const key = event.key.toLowerCase();
+ const isUndo = key === "z" && !event.shiftKey;
+ const isRedo = (key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey);
+ if ((!isUndo && !isRedo) || isTextEntry(event.target)) return;
+ if (isUndo ? !canUndo() : !canRedo()) return;
+ event.preventDefault();
+ if (isUndo) undo();
+ else redo();
+}
+
/**
* Applies a change to the open scene's working copy and marks it unsaved, for
* editing tools outside this module (e.g. future annotation tools).
@@ -554,7 +726,7 @@ function renderLibrary() {
*/
function updateImageToolbar() {
const scene = state.active;
- const index = state.selectedImageIndex;
+ const index = selectedImageIndex();
const hasSelection = Boolean(scene) && index !== null && index >= 0 && index < scene.images.length;
dom.imageToolbar.hidden = !hasSelection;
if (!hasSelection) return;
@@ -576,12 +748,15 @@ function renderCanvas() {
dom.canvas.hidden = isEmpty;
dom.fitToggle.hidden = isEmpty;
dom.canvasNote.hidden = true;
+ const showOverlapHint = Boolean(state.selection) && state.overlapCount > 1;
+ dom.selectionHint.hidden = !showOverlapHint;
+ dom.selectionHint.textContent = showOverlapHint ? overlapHint() : "";
if (isEmpty) return;
const { svg, unsupported, unlinked, width, height } = renderScene(scene, {
- selectedIndex: state.selectedImageIndex,
+ selectedIndex: selectedImageIndex(),
cropMode: state.cropMode,
- selectedAnnotationIndex: state.selectedAnnotationIndex,
+ selectedAnnotationIndex: selectedAnnotationIndex(),
draft: state.draft,
});
svg.setAttribute("aria-label", `Scene canvas for ${scene.name}`);
@@ -653,7 +828,7 @@ function renderSceneSources(scene) {
}
function showInViewer(boneId) {
- leaveEditor();
+ if (!requestLeaveEditor()) return;
document.dispatchEvent(new CustomEvent(SHOW_IN_VIEWER_EVENT, { detail: { boneId } }));
}
@@ -991,6 +1166,7 @@ async function handleSave({ overwrite = false } = {}) {
if (token === state.activeToken) {
state.saved = clone(updated);
state.savedRevision = sentRevision;
+ state.history.lastKey = null;
state.conflictUpdatedAt = null;
scene.name = updated.name;
scene.updatedAt = updated.updatedAt;
@@ -1078,6 +1254,7 @@ async function saveAsNewScene() {
else delete scene.publishedAt;
state.saved = clone(created);
state.savedRevision = revision;
+ state.history.lastKey = null;
state.missing = false;
state.saveFailed = false;
renderWorkspace();
@@ -1136,13 +1313,14 @@ async function handleDuplicate() {
/** Removes the selected image from the working copy (Issue #418). */
function handleImageRemove() {
- if (!state.active || state.selectedImageIndex === null) return;
- const index = state.selectedImageIndex;
+ if (!state.active || selectedImageIndex() === null) return;
+ const index = selectedImageIndex();
if (!state.active.images[index]) return;
state.active.images.splice(index, 1);
- deselectImage();
+ deselect();
markChanged();
renderWorkspace();
+ announce("Image removed. Save to keep the change.");
}
/**
@@ -1151,12 +1329,12 @@ function handleImageRemove() {
* toolbar buttons; a drag applies its own changes live frame-by-frame.
* @param {object} fields - The new values to set, e.g. `{ rotation: 90 }`.
*/
-function applySelectedImageUpdate(fields) {
- if (!state.active || state.selectedImageIndex === null) return;
- const image = state.active.images[state.selectedImageIndex];
+function applySelectedImageUpdate(fields, { group = null } = {}) {
+ if (!state.active || selectedImageIndex() === null) return;
+ const image = state.active.images[selectedImageIndex()];
if (!image) return;
Object.assign(image, fields);
- markChanged();
+ markChanged({ group });
renderWorkspace();
}
@@ -1166,8 +1344,8 @@ function applySelectedImageUpdate(fields) {
* @param {number} deltaDegrees
*/
function rotateSelectedImage(deltaDegrees) {
- if (!state.active || state.selectedImageIndex === null) return;
- const image = state.active.images[state.selectedImageIndex];
+ if (!state.active || selectedImageIndex() === null) return;
+ const image = state.active.images[selectedImageIndex()];
if (!image) return;
const current = typeof image.rotation === "number" ? image.rotation : 0;
const next = ((current + deltaDegrees) % 360 + 360) % 360;
@@ -1179,13 +1357,36 @@ function rotateSelectedImage(deltaDegrees) {
* @param {"horizontal" | "vertical"} axis
*/
function flipSelectedImage(axis) {
- if (!state.active || state.selectedImageIndex === null) return;
- const image = state.active.images[state.selectedImageIndex];
+ if (!state.active || selectedImageIndex() === null) return;
+ const image = state.active.images[selectedImageIndex()];
if (!image) return;
const field = axis === "horizontal" ? "flipX" : "flipY";
applySelectedImageUpdate({ [field]: !image[field] });
}
+/**
+ * Issue #442: keyboard use of the selected image on the canvas, matching
+ * annotations - the arrow keys move it (1 scene pixel, or 10 with Shift)
+ * and Delete or Backspace removes it. Not while its crop is being edited.
+ * @returns {boolean} Whether the key was handled.
+ */
+function handleImageKey(event) {
+ if (state.tool || state.cropMode || event.altKey || event.ctrlKey || event.metaKey) return false;
+ const image = state.active && selectedImageIndex() !== null ? state.active.images[selectedImageIndex()] : null;
+ if (!image) return false;
+ if (event.key === "Delete" || event.key === "Backspace") {
+ handleImageRemove();
+ dom.canvas.focus();
+ 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;
+ const [dx, dy] = deltas[event.key];
+ applySelectedImageUpdate({ x: image.x + dx, y: image.y + dy }, { group: "nudge" });
+ return true;
+}
+
/**
* Swaps the selected image with its neighbor toward the front (direction=1)
* or back (direction=-1) of the paint order (Issue #419). No-op at either end
@@ -1194,13 +1395,13 @@ function flipSelectedImage(axis) {
* @param {1 | -1} direction
*/
function moveSelectedImage(direction) {
- if (!state.active || state.selectedImageIndex === null) return;
+ if (!state.active || selectedImageIndex() === null) return;
const images = state.active.images;
- const index = state.selectedImageIndex;
+ const index = selectedImageIndex();
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= images.length) return;
[images[index], images[targetIndex]] = [images[targetIndex], images[index]];
- state.selectedImageIndex = targetIndex;
+ state.selection.index = targetIndex;
markChanged();
renderWorkspace();
}
@@ -1321,7 +1522,7 @@ function unrotateVector(dx, dy, degrees) {
*/
function startImageDrag(event, svg, corner) {
const scene = state.active;
- const index = state.selectedImageIndex;
+ const index = selectedImageIndex();
const image = scene.images[index];
const scale = getSceneScale(svg);
const startClientX = event.clientX;
@@ -1425,7 +1626,7 @@ function computeCropRect(corner, start, localDelta, imageWidth, imageHeight) {
*/
function startCropDrag(event, svg, corner) {
const scene = state.active;
- const index = state.selectedImageIndex;
+ const index = selectedImageIndex();
const image = scene.images[index];
const scale = getSceneScale(svg);
const startClientX = event.clientX;
@@ -1458,7 +1659,7 @@ function startCropDrag(event, svg, corner) {
/** Enters crop-editing mode for the selected image (Issue #417). */
function enterCropMode() {
- if (!state.active || state.selectedImageIndex === null) return;
+ if (!state.active || selectedImageIndex() === null) return;
state.cropMode = true;
renderCanvas();
}
@@ -1472,8 +1673,8 @@ function exitCropMode() {
/** Clears the selected image's crop back to fully visible. */
function resetSelectedImageCrop() {
- if (!state.active || state.selectedImageIndex === null) return;
- const image = state.active.images[state.selectedImageIndex];
+ if (!state.active || selectedImageIndex() === null) return;
+ const image = state.active.images[selectedImageIndex()];
if (!image) return;
applySelectedImageUpdate({ cropX: 0, cropY: 0, cropWidth: image.width, cropHeight: image.height });
}
@@ -1509,14 +1710,159 @@ const round2 = (value) => Math.round(value * 100) / 100;
function selectedAnnotation() {
const scene = state.active;
- const index = state.selectedAnnotationIndex;
+ const index = selectedAnnotationIndex();
if (!scene || index === null || index < 0 || index >= scene.annotations.length) return null;
return scene.annotations[index];
}
-function selectAnnotation(index) {
- deselectImage();
- state.selectedAnnotationIndex = index;
+// Issue #440: selecting objects with the mouse and keyboard. A click selects
+// the object under it; Escape or a click on empty canvas clears the
+// selection; Tab and Shift+Tab step through the drawn objects from the top of
+// the stack down.
+// Issue #441: where objects overlap, clicking again in the same spot (or
+// Alt+clicking anywhere on the stack) selects the next object down, wrapping
+// back to the top, so an image under a highlight can still be reached.
+const MAX_ANNOUNCED_TEXT = 40;
+// How far apart, in screen pixels, two clicks can be and still count as the same spot.
+const SAME_SPOT_DISTANCE = 4;
+// Where the last selecting click was, in client coordinates, or null.
+let lastSelectClick = null;
+
+/**
+ * Every drawn object as { kind, index }, topmost first: annotations are
+ * drawn over images, and each later object over the ones before it.
+ * Objects the canvas can't draw aren't included.
+ */
+function selectableObjects() {
+ const svg = dom.canvas.querySelector("svg");
+ if (!svg) return [];
+ const annotationIndexes = new Set(
+ [...svg.querySelectorAll("[data-scene-annotation-index]")].map((node) => Number(node.dataset.sceneAnnotationIndex))
+ );
+ const imageIndexes = [...svg.querySelectorAll("[data-scene-image-index]")].map((node) => Number(node.dataset.sceneImageIndex));
+ return [
+ ...[...annotationIndexes].reverse().map((index) => ({ kind: "annotation", index })),
+ ...imageIndexes.reverse().map((index) => ({ kind: "image", index })),
+ ];
+}
+
+function announceSelection() {
+ const { kind, index } = state.selection;
+ let name = "image";
+ if (kind === "annotation") {
+ const annotation = selectedAnnotation();
+ name = describeAnnotation(annotation);
+ if (annotation.type === "text") {
+ const text = annotation.text.replace(/\s+/g, " ").trim();
+ name += ` "${text.length > MAX_ANNOUNCED_TEXT ? `${text.slice(0, MAX_ANNOUNCED_TEXT)}…` : text}"`;
+ }
+ }
+ const objects = selectableObjects();
+ const position = objects.findIndex((object) => object.kind === kind && object.index === index);
+ const message = position === -1 ? `Selected ${name}.` : `Selected ${name}, ${position + 1} of ${plural(objects.length, "object")}.`;
+ announce(state.overlapCount > 1 ? `${message} ${overlapHint()}` : message);
+}
+
+function overlapHint() {
+ return `${state.overlapCount} objects overlap here. Click again in the same spot, or Alt+click, to select the next one down.`;
+}
+
+/**
+ * Selects an object the user picked, and says which one it is.
+ * @param {"image"|"annotation"} kind
+ * @param {number} index
+ * @param {{ overlapCount?: number }} [options] - how many objects were under the click.
+ */
+function selectObject(kind, index, { overlapCount = 0 } = {}) {
+ select(kind, index);
+ state.overlapCount = overlapCount;
+ keepCanvasInPlace(renderCanvas);
+ announceSelection();
+}
+
+/**
+ * Redraws while keeping the canvas where it is on screen. Selecting shows the
+ * image or annotation toolbar above the canvas (and clearing hides it), which
+ * would otherwise push the scene out from under the pointer, so the next
+ * click "in the same spot" would land on a different part of it.
+ */
+function keepCanvasInPlace(redraw) {
+ const before = dom.canvas.getBoundingClientRect().top;
+ redraw();
+ const shift = dom.canvas.getBoundingClientRect().top - before;
+ // Instant, not the page's smooth scrolling, so a quick next click finds the canvas already back.
+ if (shift) window.scrollBy({ top: shift, behavior: "instant" });
+}
+
+/** The object a canvas node belongs to, as { kind, index }, or null. */
+function objectOfNode(node) {
+ const annotationNode = node.closest("[data-scene-annotation-index]");
+ if (annotationNode) return { kind: "annotation", index: Number(annotationNode.dataset.sceneAnnotationIndex) };
+ const imageNode = node.closest("[data-scene-image-index]");
+ return imageNode ? { kind: "image", index: Number(imageNode.dataset.sceneImageIndex) } : null;
+}
+
+/**
+ * Every object under a mouse event, topmost first. The one the browser
+ * reports as clicked always comes first, so the result agrees with what the
+ * user sees on top even where hit areas and geometry differ slightly.
+ */
+function objectsUnderEvent(event) {
+ const svg = dom.canvas.querySelector("svg");
+ const clicked = objectOfNode(event.target);
+ const stack = svg && svg.contains(event.target) ? objectsAt(state.active, clientToScene(svg, event)) : [];
+ if (!clicked) return stack;
+ return [clicked, ...stack.filter((object) => object.kind !== clicked.kind || object.index !== clicked.index)];
+}
+
+/** A click on the canvas with no tool in use: selects, steps down a stack of objects, or clears. */
+function handleSelectClick(event) {
+ const sameSpot = Boolean(lastSelectClick) &&
+ Math.hypot(event.clientX - lastSelectClick.x, event.clientY - lastSelectClick.y) <= SAME_SPOT_DISTANCE;
+ lastSelectClick = { x: event.clientX, y: event.clientY };
+ let stack = objectsUnderEvent(event);
+ if (state.cropMode) {
+ // While cropping, annotations can't be selected; a click on one does nothing.
+ if (stack.length > 0 && stack[0].kind === "annotation") return;
+ stack = stack.filter((object) => object.kind === "image");
+ }
+ if (stack.length === 0) {
+ clearSelection();
+ return;
+ }
+ const current = stack.findIndex(isSelected);
+ let target = stack[0];
+ if (current !== -1 && (sameSpot || event.altKey)) target = stack[(current + 1) % stack.length];
+ else if (event.altKey && stack.length > 1) target = stack[1];
+ selectObject(target.kind, target.index, { overlapCount: stack.length });
+}
+
+/** Clears the selection the user dismissed. `quiet` skips the announcement. */
+function clearSelection({ quiet = false } = {}) {
+ if (!state.selection) return;
+ deselect();
+ keepCanvasInPlace(renderCanvas);
+ if (!quiet) announce("Nothing selected.");
+}
+
+/**
+ * Tab and Shift+Tab on the canvas select the next or previous object. Past
+ * either end the selection is cleared and focus leaves the canvas as usual.
+ * @returns {boolean} Whether the key was handled.
+ */
+function handleSelectionTab(event) {
+ if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey) return false;
+ if (!state.active || state.tool || state.cropMode) return false;
+ const objects = selectableObjects();
+ const current = objects.findIndex(isSelected);
+ if (current === -1 && event.shiftKey) return false;
+ const next = event.shiftKey ? current - 1 : current + 1;
+ if (next < 0 || next >= objects.length) {
+ clearSelection({ quiet: true });
+ return false;
+ }
+ selectObject(objects[next].kind, objects[next].index);
+ return true;
}
/** Where a mouse event is in scene coordinates. */
@@ -1582,10 +1928,10 @@ function updateAnnotationControls() {
}
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;
+ dom.annotationBack.disabled = selectedAnnotationIndex() === 0;
+ dom.annotationBackward.disabled = selectedAnnotationIndex() === 0;
+ dom.annotationForward.disabled = selectedAnnotationIndex() === last;
+ dom.annotationFront.disabled = selectedAnnotationIndex() === last;
}
/**
@@ -1601,10 +1947,7 @@ function setTool(tool) {
endLineDraw();
state.tool = next;
state.draft = null;
- if (next && next !== "pointer") {
- deselectImage();
- deselectAnnotation();
- }
+ if (next && next !== "pointer") deselect();
renderCanvas();
if (next) {
announce(TOOL_HINTS[next]);
@@ -1626,7 +1969,7 @@ function addAnnotation(annotation) {
state.active.annotations.push(annotation);
state.tool = null;
state.draft = null;
- selectAnnotation(state.active.annotations.length - 1);
+ select("annotation", state.active.annotations.length - 1);
markChanged();
renderWorkspace();
}
@@ -1856,23 +2199,24 @@ function nudgeSelectedAnnotation(dx, dy) {
const pointers = pointerLinesOf(annotation, scene);
storeAnnotationGeometry(annotation, translateAnnotation(start, dx, dy), scene);
movePointerLines(pointers, startBox, dx, dy, scene);
- markChanged();
+ markChanged({ group: "nudge" });
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.
+ * @param {{ reanchor?: boolean, group?: string }} [options] - reanchor puts a
+ * label's pointer lines back on its edge after its size or text changed;
+ * group is passed to markChanged, so a run of these edits undoes as one.
*/
-function editSelectedAnnotation(edit, { reanchor = false } = {}) {
+function editSelectedAnnotation(edit, { reanchor = false, group = null } = {}) {
const annotation = selectedAnnotation();
if (!annotation) return;
const scene = state.active;
edit(annotation, scene);
if (reanchor) reanchorPointerLines(annotation, scene);
- markChanged();
+ markChanged({ group });
renderWorkspace();
}
@@ -1889,7 +2233,7 @@ function handleAnnotationTextInput() {
dom.annotationText.removeAttribute("aria-invalid");
editSelectedAnnotation((annotation) => {
annotation.text = text;
- }, { reanchor: true });
+ }, { reanchor: true, group: "text" });
}
/** Leaving the text box empty keeps the label's last text. */
@@ -1933,7 +2277,7 @@ function handleAnnotationColorInput() {
} else {
annotation.stroke = color;
}
- });
+ }, { group: "color" });
}
/**
@@ -1946,7 +2290,7 @@ function handleAnnotationOpacityInput() {
editSelectedAnnotation((annotation) => {
annotation.fillOpacity = clampHighlightOpacity(opacity);
delete annotation.opacity;
- });
+ }, { group: "opacity" });
}
/**
@@ -1956,14 +2300,14 @@ function handleAnnotationOpacityInput() {
*/
function moveSelectedAnnotation(direction) {
const annotations = state.active ? state.active.annotations : null;
- const index = state.selectedAnnotationIndex;
+ const index = 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;
+ state.selection.index = target;
markChanged();
renderWorkspace();
const position = target === last ? "in front of all other annotations"
@@ -1979,7 +2323,7 @@ function deleteSelectedAnnotation() {
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();
+ deselect();
markChanged();
renderWorkspace();
const kind = describeAnnotation(annotation);
@@ -2019,8 +2363,7 @@ function handleAnnotationKey(event) {
}
if (state.tool || !selectedAnnotation()) return false;
if (event.key === "Escape") {
- deselectAnnotation();
- renderCanvas();
+ clearSelection();
return true;
}
if (event.key === "Delete" || event.key === "Backspace") {
@@ -2340,6 +2683,28 @@ function enterEditor() {
loadLibrary();
}
+/**
+ * Issue #444: leaves the editor, asking first when the open scene has unsaved
+ * changes. Leaving doesn't discard them - the working copy stays open and is
+ * still here on coming back - but nothing outside the editor shows them, and
+ * closing or reloading the page would lose them.
+ * @returns {boolean} Whether the editor was left.
+ */
+function requestLeaveEditor() {
+ if (hasUnsavedChanges()) {
+ const leave = window.confirm(
+ `"${state.active.name}" has unsaved changes. Leave the Scene Editor without saving? ` +
+ "They stay here until you close or reload the page, but the bone viewer only shows saved scenes."
+ );
+ if (!leave) {
+ announce("Still in the Scene Editor. Your changes aren't saved yet.");
+ return false;
+ }
+ }
+ leaveEditor();
+ return true;
+}
+
function leaveEditor() {
dom.editorView.hidden = true;
dom.viewer.hidden = false;
@@ -2377,6 +2742,8 @@ export function initializeSceneEditor(root = document) {
sceneSources: $("scene-sources"),
saveStatus: $("scene-save-status"),
saveButton: $("scene-save"),
+ undoButton: $("scene-undo"),
+ redoButton: $("scene-redo"),
duplicateButton: $("scene-duplicate"),
renameButton: $("scene-rename"),
deleteButton: $("scene-delete"),
@@ -2392,6 +2759,7 @@ export function initializeSceneEditor(root = document) {
canvas: $("scene-canvas"),
canvasEmpty: $("scene-canvas-empty"),
canvasNote: $("scene-canvas-note"),
+ selectionHint: $("scene-selection-hint"),
importButton: $("scene-import-image"),
importButtonEmpty: $("scene-canvas-empty-import"),
importInput: $("scene-import-input"),
@@ -2447,9 +2815,9 @@ export function initializeSceneEditor(root = document) {
dom.enterButton.addEventListener("click", () => {
if (dom.editorView.hidden) enterEditor();
- else leaveEditor();
+ else requestLeaveEditor();
});
- dom.backButton.addEventListener("click", leaveEditor);
+ dom.backButton.addEventListener("click", requestLeaveEditor);
const editorView = dom.editorView;
document.addEventListener(OPEN_SCENE_EVENT, (event) => {
// Skip if this page was re-rendered and this editor is no longer on it.
@@ -2474,6 +2842,9 @@ export function initializeSceneEditor(root = document) {
});
dom.deleteButton.addEventListener("click", handleDelete);
if (dom.saveButton) dom.saveButton.addEventListener("click", () => handleSave());
+ dom.undoButton.addEventListener("click", undo);
+ dom.redoButton.addEventListener("click", redo);
+ document.addEventListener("keydown", handleUndoKey);
if (dom.duplicateButton) dom.duplicateButton.addEventListener("click", handleDuplicate);
if (dom.publishButton) dom.publishButton.addEventListener("click", handlePublishToggle);
dom.fitToggle.addEventListener("click", () => setFitToWidth(!state.fitToWidth));
@@ -2505,25 +2876,9 @@ export function initializeSceneEditor(root = document) {
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) {
- deselectImage();
- } else {
- state.selectedImageIndex = index;
- }
- renderCanvas();
+ // A click on a handle belongs to the selected object, even without a drag.
+ if (event.target.closest("[data-annotation-handle], [data-corner]")) return;
+ handleSelectClick(event);
});
dom.canvas.addEventListener("dblclick", (event) => {
if (state.tool === "polygon") {
@@ -2531,25 +2886,19 @@ export function initializeSceneEditor(root = document) {
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];
+ // The two clicks have already picked the object (stepping down a
+ // stack of overlapping ones, #441), so only a selected label opens.
+ const annotation = selectedAnnotation();
if (!annotation || annotation.type !== "text") return;
- selectAnnotation(index);
- renderCanvas();
dom.annotationText.focus();
dom.annotationText.select();
});
dom.canvas.addEventListener("keydown", (event) => {
- if (handleAnnotationKey(event)) {
+ if (handleAnnotationKey(event) || handleImageKey(event) || handleSelectionTab(event)) {
event.preventDefault();
return;
}
- if (event.key === "Escape" && state.selectedImageIndex !== null) {
- deselectImage();
- renderCanvas();
- }
+ if (event.key === "Escape" && state.selection) clearSelection();
});
// Issue #446: presses come in as pointer events (mouse, touch, pen) where
// the browser supports them, with plain mouse events as the fallback. A
@@ -2577,23 +2926,26 @@ export function initializeSceneEditor(root = document) {
return;
}
if (state.tool) return;
+ // Issue #442: a press anywhere on the selected object drags it, even
+ // where another object is drawn over it (it was reached with #441's
+ // click-again), and a press on anything else never moves anything.
+ const isOnSelected = () => objectsUnderEvent(event).some(isSelected);
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) {
+ } else if (isOnSelected()) {
event.preventDefault();
startAnnotationDrag(event, annotationSvg, "move");
}
return;
}
- if (state.selectedImageIndex === null) return;
- const image = state.active.images[state.selectedImageIndex];
+ if (selectedImageIndex() === null) return;
+ const image = state.active.images[selectedImageIndex()];
if (!image) return;
const handle = event.target.closest("[data-corner]");
@@ -2607,10 +2959,7 @@ export function initializeSceneEditor(root = document) {
return;
}
- const imageNode = event.target.closest("[data-scene-image-index]");
- const isSelectedImageNode =
- imageNode && Number(imageNode.dataset.sceneImageIndex) === state.selectedImageIndex;
- if (!handle && !isSelectedImageNode) return;
+ if (!handle && !isOnSelected()) return;
event.preventDefault();
startImageDrag(event, svg, handle ? handle.dataset.corner : null);
diff --git a/templates/style.css b/templates/style.css
index 2b05ac5..3d0b9bf 100644
--- a/templates/style.css
+++ b/templates/style.css
@@ -3122,6 +3122,12 @@ button:disabled {
color: #92400e;
}
+.scene-selection-hint {
+ margin: var(--space-2) 0 0;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+}
+
.scene-image-object {
cursor: pointer;
}
diff --git a/templates/tests/sceneAnnotationEditor.test.js b/templates/tests/sceneAnnotationEditor.test.js
index 04425ca..782546b 100644
--- a/templates/tests/sceneAnnotationEditor.test.js
+++ b/templates/tests/sceneAnnotationEditor.test.js
@@ -91,8 +91,18 @@ function drag(target, from, to) {
function key(name, options = {}) {
$("scene-canvas").dispatchEvent(new KeyboardEvent("keydown", { key: name, bubbles: true, ...options }));
}
+/** A point on a drawn annotation, where a user would click it. */
+function pointOn(node) {
+ if (node.tagName === "text") return [Number(node.getAttribute("x")) + 1, Number(node.getAttribute("y")) + 1];
+ const points = (node.getAttribute("points") || "").trim().split(/\s+/).map((pair) => pair.split(",").map(Number));
+ if (node.tagName === "polygon") {
+ return [0, 1].map((axis) => points.reduce((sum, point) => sum + point[axis], 0) / points.length);
+ }
+ return [(points[0][0] + points[1][0]) / 2, (points[0][1] + points[1][1]) / 2];
+}
function selectAnnotation(index) {
- clickAt(annotationNode(index), [0, 0]);
+ const node = annotationNode(index);
+ clickAt(node, pointOn(node));
}
function typeText(text) {
$("scene-annotation-text").focus();
@@ -233,7 +243,10 @@ describe("Text labels (#427-#430)", () => {
it("opens a label's text for editing on double-click", async () => {
await openBlankScene({ annotations: [{ id: "a", type: "text", text: "Ilium", x: 10, y: 10 }] });
- mouse("dblclick", annotationNode(0), [0, 0]);
+ // A browser sends both clicks before the dblclick.
+ clickAt(annotationNode(0), [12, 12]);
+ clickAt(annotationNode(0), [12, 12]);
+ mouse("dblclick", annotationNode(0), [12, 12]);
expect(document.activeElement).toBe($("scene-annotation-text"));
expect($("scene-annotation-toolbar").hidden).toBe(false);
});
@@ -724,6 +737,325 @@ describe("Annotation selection", () => {
expect($("scene-canvas-empty").hidden).toBe(false);
});
});
+
+describe("Selecting objects (#440)", () => {
+ // One of each object type. Annotations are drawn over images, and later
+ // ones over earlier ones, so from the top down: highlight, arrow, line,
+ // label, image.
+ const content = {
+ images: [{ id: "img", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100 }],
+ annotations: [
+ { id: "label", type: "text", text: "Ilium", x: 0, y: 0 },
+ { id: "line", type: "line", x1: 10, y1: 400, x2: 200, y2: 400 },
+ { id: "arrow", type: "arrow", x1: 10, y1: 450, x2: 200, y2: 450 },
+ { id: "poly", type: "polygon", points: [[500, 100], [600, 100], [550, 200]] },
+ ],
+ };
+ const imageNode = () => document.querySelector("[data-scene-image-index=\"0\"]");
+ const status = () => $("scene-editor-status").textContent;
+ const outlines = () => document.querySelectorAll(".scene-selection-outline");
+ /** Presses a key on the canvas; returns whether the editor handled it (prevented the default). */
+ function press(name, options = {}) {
+ return !$("scene-canvas").dispatchEvent(new KeyboardEvent("keydown", { key: name, bubbles: true, cancelable: true, ...options }));
+ }
+
+ it("selects each object type with a visible outline, and a second click keeps it selected", async () => {
+ await openBlankScene(content);
+
+ for (const index of [0, 1, 2, 3]) {
+ selectAnnotation(index);
+ expect($("scene-annotation-toolbar").hidden).toBe(false);
+ expect($("scene-image-toolbar").hidden).toBe(true);
+ expect(outlines()).toHaveLength(1);
+ selectAnnotation(index);
+ expect($("scene-annotation-toolbar").hidden).toBe(false);
+ }
+ clickAt(imageNode(), [350, 350]);
+ expect($("scene-image-toolbar").hidden).toBe(false);
+ expect($("scene-annotation-toolbar").hidden).toBe(true);
+ expect(outlines()).toHaveLength(1);
+ clickAt(imageNode(), [350, 350]);
+ expect($("scene-image-toolbar").hidden).toBe(false);
+ });
+
+ it("says which object was selected and where it is in the stack", async () => {
+ await openBlankScene(content);
+
+ selectAnnotation(0);
+ await waitFor(() => expect(status()).toBe("Selected label \"Ilium\", 4 of 5 objects."));
+ selectAnnotation(3);
+ await waitFor(() => expect(status()).toBe("Selected highlight, 1 of 5 objects."));
+ clickAt(imageNode(), [350, 350]);
+ await waitFor(() => expect(status()).toBe("Selected image, 5 of 5 objects."));
+ });
+
+ it("clears the selection with Escape or a click on empty canvas", async () => {
+ await openBlankScene(content);
+
+ clickAt(imageNode(), [350, 350]);
+ expect(press("Escape")).toBe(false);
+ expect($("scene-image-toolbar").hidden).toBe(true);
+ expect(outlines()).toHaveLength(0);
+ await waitFor(() => expect(status()).toBe("Nothing selected."));
+
+ selectAnnotation(1);
+ clickAt(canvasSvg(), [900, 550]);
+ expect($("scene-annotation-toolbar").hidden).toBe(true);
+ expect(outlines()).toHaveLength(0);
+ });
+
+ it("keeps an image selected when one of its corner handles is clicked without dragging", async () => {
+ await openBlankScene(content);
+ clickAt(imageNode(), [350, 350]);
+
+ clickAt(document.querySelector("[data-corner=\"se\"]"), [400, 400]);
+
+ expect($("scene-image-toolbar").hidden).toBe(false);
+ expect(scenesModule.getActiveScene().images[0]).toMatchObject({ x: 300, y: 300, width: 100, height: 100 });
+ expect(scenesModule.hasUnsavedChanges()).toBe(false);
+ });
+
+ it("steps through the objects from the top with Tab and back with Shift+Tab, then lets focus leave", async () => {
+ await openBlankScene(content);
+ const selected = () => {
+ if (!$("scene-image-toolbar").hidden) return "image";
+ if (!$("scene-annotation-toolbar").hidden) return "annotation";
+ return null;
+ };
+ const order = [];
+ for (let step = 0; step < 5; step += 1) {
+ expect(press("Tab")).toBe(true);
+ await waitFor(() => expect(status()).toMatch(new RegExp(`, ${step + 1} of 5 objects\\.$`)));
+ order.push(status());
+ }
+ expect(order.map((message) => message.replace(/, \d of 5 objects\.$/, ""))).toEqual([
+ "Selected highlight",
+ "Selected arrow",
+ "Selected line",
+ "Selected label \"Ilium\"",
+ "Selected image",
+ ]);
+ expect(selected()).toBe("image");
+
+ expect(press("Tab", { shiftKey: true })).toBe(true);
+ await waitFor(() => expect(status()).toBe("Selected label \"Ilium\", 4 of 5 objects."));
+
+ // Past the last object, Tab clears the selection and isn't held by the canvas.
+ press("Tab");
+ expect(press("Tab")).toBe(false);
+ expect(selected()).toBeNull();
+ // With nothing selected, Shift+Tab leaves the canvas too.
+ expect(press("Tab", { shiftKey: true })).toBe(false);
+ });
+
+ it("leaves Tab alone while a drawing tool is in use", async () => {
+ await openBlankScene(content);
+ click("scene-tool-line");
+ expect(press("Tab")).toBe(false);
+ expect(outlines()).toHaveLength(0);
+ });
+});
+
+describe("Selecting overlapping objects (#441)", () => {
+ // A label on a highlight on an image: at (320, 315) all three overlap; at
+ // (390, 390) the highlight and the image; at (285, 285) only the highlight.
+ const content = {
+ images: [{ id: "img", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100 }],
+ annotations: [
+ { id: "poly", type: "polygon", points: [[280, 280], [420, 280], [420, 420], [280, 420]] },
+ { id: "label", type: "text", text: "Ilium", x: 310, y: 310 },
+ ],
+ };
+ const status = () => $("scene-editor-status").textContent;
+ const hint = () => $("scene-selection-hint");
+ const selected = () => {
+ if (!$("scene-image-toolbar").hidden) return "image";
+ if ($("scene-annotation-toolbar").hidden) return null;
+ return $("scene-annotation-label").textContent.includes("label") ? "label" : "highlight";
+ };
+ function altClickAt(target, [x, y]) {
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: x, clientY: y, altKey: true }));
+ document.dispatchEvent(new MouseEvent("mouseup", { clientX: x, clientY: y, altKey: true }));
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y, altKey: true }));
+ }
+
+ it("selects the topmost object, then each one below on clicks in the same spot, wrapping to the top", async () => {
+ await openBlankScene(content);
+ const order = [];
+ for (let i = 0; i < 4; i += 1) {
+ // The browser reports the topmost node as clicked every time.
+ clickAt(annotationNode(1), [320, 315]);
+ order.push(selected());
+ }
+ expect(order).toEqual(["label", "highlight", "image", "label"]);
+ });
+
+ it("shows and announces a hint while objects overlap, and hides it for a single object", async () => {
+ await openBlankScene(content);
+
+ clickAt(annotationNode(1), [320, 315]);
+ expect(hint().hidden).toBe(false);
+ expect(hint().textContent).toBe(
+ "3 objects overlap here. Click again in the same spot, or Alt+click, to select the next one down."
+ );
+ await waitFor(() => expect(status()).toBe(
+ "Selected label \"Ilium\", 1 of 3 objects. 3 objects overlap here. Click again in the same spot, or Alt+click, to select the next one down."
+ ));
+
+ clickAt(annotationNode(0), [285, 285]);
+ expect(selected()).toBe("highlight");
+ expect(hint().hidden).toBe(true);
+
+ clickAt(annotationNode(1), [320, 315]);
+ key("Escape");
+ expect(hint().hidden).toBe(true);
+ });
+
+ it("selects the topmost object again when the click is in a different spot", async () => {
+ await openBlankScene(content);
+ clickAt(annotationNode(0), [390, 390]);
+ clickAt(annotationNode(0), [390, 390]);
+ expect(selected()).toBe("image");
+
+ clickAt(annotationNode(0), [395, 380]);
+ expect(selected()).toBe("highlight");
+ });
+
+ it("steps down the stack with Alt+click from anywhere on it", async () => {
+ await openBlankScene(content);
+
+ altClickAt(annotationNode(0), [390, 390]);
+ expect(selected()).toBe("image");
+ // The image is under the label too, so the next Alt+click wraps to the top there.
+ altClickAt(annotationNode(1), [320, 315]);
+ expect(selected()).toBe("label");
+ altClickAt(annotationNode(1), [322, 316]);
+ expect(selected()).toBe("highlight");
+ });
+
+ it("keeps stepping down on quick clicks instead of a double-click jumping back to the label", async () => {
+ await openBlankScene(content);
+ clickAt(annotationNode(1), [320, 315]);
+ clickAt(annotationNode(1), [320, 315]);
+ mouse("dblclick", annotationNode(1), [320, 315]);
+ expect(selected()).toBe("highlight");
+ expect(document.activeElement).not.toBe($("scene-annotation-text"));
+
+ clickAt(annotationNode(1), [320, 315]);
+ expect(selected()).toBe("image");
+ });
+
+ it("clears the hint once the selected object is edited", async () => {
+ await openBlankScene(content);
+ clickAt(annotationNode(1), [320, 315]);
+ expect(hint().hidden).toBe(false);
+
+ key("ArrowRight");
+
+ expect(hint().hidden).toBe(true);
+ expect(selected()).toBe("label");
+ });
+});
+
+describe("Moving and resizing selected objects (#442)", () => {
+ // A crowded scene: image "a" under a highlight and a label, image "b" to
+ // its right, and a line below.
+ const content = () => ({
+ images: [
+ { id: "a", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100 },
+ { id: "b", src: "/images/b.png", x: 600, y: 300, width: 100, height: 100 },
+ ],
+ annotations: [
+ { id: "poly", type: "polygon", points: [[280, 280], [420, 280], [420, 420], [280, 420]] },
+ { id: "label", type: "text", text: "Ilium", x: 310, y: 310 },
+ { id: "line", type: "line", x1: 10, y1: 500, x2: 200, y2: 500 },
+ ],
+ });
+ const images = () => scenesModule.getActiveScene().images;
+ const storedImages = () => backend.scenes.get("scene-1").images;
+ const imageNode = (index) => document.querySelector(`[data-scene-image-index="${index}"]`);
+ const status = () => $("scene-editor-status").textContent;
+ /** Selects image "a" the way a user reaches it under the highlight: clicking twice in one spot. */
+ function selectCoveredImage() {
+ clickAt(annotationNode(0), [390, 390]);
+ clickAt(annotationNode(0), [390, 390]);
+ expect($("scene-image-toolbar").hidden).toBe(false);
+ }
+
+ it("moves, nudges, and resizes an image covered by a highlight, and keeps it after reopening", async () => {
+ await openBlankScene(content());
+ selectCoveredImage();
+
+ // The press lands on the highlight, but the selected image is under it, so the image moves.
+ drag(annotationNode(0), [390, 390], [440, 400]);
+ expect(images()[0]).toMatchObject({ x: 350, y: 310 });
+ expect($("scene-image-toolbar").hidden).toBe(false);
+
+ key("ArrowRight");
+ key("ArrowDown", { shiftKey: true });
+ expect(images()[0]).toMatchObject({ x: 351, y: 320 });
+
+ // The se handle is at (451, 420); dragging it 50 right scales 100 -> 150 about the center.
+ drag(document.querySelector("[data-corner=\"se\"]"), [451, 420], [501, 420]);
+ expect(images()[0]).toMatchObject({ x: 326, y: 295, width: 150, height: 150 });
+
+ await saveAndReopen();
+ expect(storedImages()[0]).toMatchObject({ x: 326, y: 295, width: 150, height: 150 });
+ expect(storedImages()[1]).toEqual(content().images[1]);
+ expect(stored()).toEqual(content().annotations);
+ });
+
+ it("never moves an object that isn't selected", async () => {
+ await openBlankScene(content());
+ selectCoveredImage();
+
+ // Pressing and dragging on the line or on image "b" moves nothing.
+ drag(annotationNode(2), [100, 500], [150, 550]);
+ drag(imageNode(1), [650, 350], [700, 400]);
+
+ expect(images()).toEqual(content().images);
+ expect(annotations()).toEqual(content().annotations);
+ expect(scenesModule.hasUnsavedChanges()).toBe(false);
+ });
+
+ it("moves a selected highlight when the press lands on the label drawn over it", async () => {
+ await openBlankScene(content());
+ clickAt(annotationNode(0), [390, 390]);
+
+ drag(annotationNode(1), [320, 315], [330, 325]);
+
+ expect(annotations()[0].points).toEqual([[290, 290], [430, 290], [430, 430], [290, 430]]);
+ expect(annotations()[1]).toEqual(content().annotations[1]);
+ expect(images()).toEqual(content().images);
+ });
+
+ it("removes the selected image with Delete or Backspace", async () => {
+ await openBlankScene(content());
+ selectCoveredImage();
+
+ key("Delete");
+ expect(images().map((image) => image.id)).toEqual(["b"]);
+ expect($("scene-image-toolbar").hidden).toBe(true);
+ await waitFor(() => expect(status()).toBe("Image removed. Save to keep the change."));
+
+ clickAt(imageNode(0), [650, 350]);
+ key("Backspace");
+ expect(images()).toEqual([]);
+ expect(annotations()).toEqual(content().annotations);
+ });
+
+ it("leaves the arrow keys and Delete alone while an image's crop is being edited", async () => {
+ await openBlankScene(content());
+ clickAt(imageNode(1), [650, 350]);
+ click("scene-image-crop-start");
+
+ key("ArrowLeft");
+ key("Delete");
+
+ expect(images()).toEqual(content().images);
+ });
+});
+
// Issue #446: the same edits made with pointer events, which is what a mouse
// sends in browsers that support them (and touch and pen, later). jsdom may
// not have PointerEvent, so the fields the editor reads are set by hand.
@@ -947,4 +1279,221 @@ describe("Mouse editing with pointer events (#446)", () => {
expect(annotations()).toEqual([]);
expect(document.querySelector(".scene-draft")).toBeNull();
});
-});
\ No newline at end of file
+});
+
+describe("Undo and redo (#443)", () => {
+ const content = () => ({
+ images: [{ id: "img", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100 }],
+ annotations: [
+ { id: "label", type: "text", text: "Ilium", x: 20, y: 20 },
+ { id: "poly", type: "polygon", points: [[600, 100], [700, 100], [650, 200]] },
+ ],
+ });
+ const clone = (value) => JSON.parse(JSON.stringify(value));
+ const images = () => scenesModule.getActiveScene().images;
+ const imageNode = () => document.querySelector("[data-scene-image-index=\"0\"]");
+ const saveStatus = () => $("scene-save-status").textContent;
+ const status = () => $("scene-editor-status").textContent;
+ function shortcut(name, options = {}, target = $("scene-canvas")) {
+ const event = new KeyboardEvent("keydown", { key: name, bubbles: true, cancelable: true, ...options });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+ }
+
+ it("undoes and redoes a sequence of image and annotation edits, one step at a time", async () => {
+ await openBlankScene(content());
+ expect($("scene-undo").disabled).toBe(true);
+ expect($("scene-redo").disabled).toBe(true);
+
+ clickAt(imageNode(), [350, 350]);
+ drag(imageNode(), [350, 350], [400, 380]);
+ selectAnnotation(1);
+ click("scene-annotation-delete");
+ await addLabel([100, 400], "Pubis");
+ const edited = clone(scenesModule.getActiveScene());
+ expect(edited.annotations.map((a) => a.text || a.id)).toEqual(["Ilium", "Pubis"]);
+
+ // Typing the new label's text, then adding it, are two steps.
+ click("scene-undo");
+ expect(annotations().map((a) => a.text || a.id)).toEqual(["Ilium", "New label"]);
+ click("scene-undo");
+ expect(annotations().map((a) => a.text || a.id)).toEqual(["Ilium"]);
+ click("scene-undo");
+ expect(annotations()).toEqual(content().annotations);
+ expect(images()[0]).toMatchObject({ x: 350, y: 330 });
+ click("scene-undo");
+ expect(images()).toEqual(content().images);
+ expect($("scene-undo").disabled).toBe(true);
+ expect(saveStatus()).toBe("Saved");
+
+ for (let step = 0; step < 4; step += 1) click("scene-redo");
+ expect(clone(scenesModule.getActiveScene()).images).toEqual(edited.images);
+ expect(clone(scenesModule.getActiveScene()).annotations).toEqual(edited.annotations);
+ expect($("scene-redo").disabled).toBe(true);
+ await waitFor(() => expect(status()).toBe("Redone. Nothing more to redo."));
+ });
+
+ it("undoes a run of typing or arrow-key nudges as one step, but not edits of another object", async () => {
+ await openBlankScene(content());
+ selectAnnotation(0);
+ typeText("Il");
+ typeText("Ili");
+ typeText("Ilio");
+ key("ArrowRight");
+ key("ArrowRight");
+ key("ArrowDown");
+ clickAt(imageNode(), [350, 350]);
+ key("ArrowLeft");
+ key("ArrowLeft");
+
+ click("scene-undo");
+ expect(images()[0].x).toBe(300);
+ expect(annotations()[0]).toMatchObject({ text: "Ilio", x: 22, y: 21 });
+ click("scene-undo");
+ expect(annotations()[0]).toMatchObject({ text: "Ilio", x: 20, y: 20 });
+ click("scene-undo");
+ expect(annotations()[0]).toEqual(content().annotations[0]);
+ expect($("scene-undo").disabled).toBe(true);
+ });
+
+ it("shows the scene as saved again when undo or redo returns to the saved version", async () => {
+ await openBlankScene(content());
+ selectAnnotation(0);
+ key("ArrowRight", { shiftKey: true });
+ await saveAndWait();
+
+ click("scene-undo");
+ expect(saveStatus()).toBe("Unsaved changes");
+ expect(scenesModule.hasUnsavedChanges()).toBe(true);
+ click("scene-redo");
+ expect(saveStatus()).toBe("Saved");
+ expect(scenesModule.hasUnsavedChanges()).toBe(false);
+
+ // A new edit after an undo replaces what could be redone, and is never mistaken for the saved version.
+ click("scene-undo");
+ key("ArrowDown");
+ expect($("scene-redo").disabled).toBe(true);
+ expect(scenesModule.hasUnsavedChanges()).toBe(true);
+ });
+
+ it("keeps the selected object selected when it still exists, and saves the undone state", async () => {
+ await openBlankScene(content());
+ selectAnnotation(0);
+ key("ArrowRight");
+ click("scene-annotation-delete");
+ expect($("scene-annotation-toolbar").hidden).toBe(true);
+
+ click("scene-undo");
+ expect(annotations()).toHaveLength(2);
+ expect($("scene-annotation-toolbar").hidden).toBe(true);
+
+ selectAnnotation(0);
+ click("scene-undo");
+ expect(annotations()[0]).toEqual(content().annotations[0]);
+ expect($("scene-annotation-toolbar").hidden).toBe(false);
+ expect($("scene-annotation-text").value).toBe("Ilium");
+
+ click("scene-redo");
+ await saveAndReopen();
+ expect(stored()[0]).toMatchObject({ x: 21, y: 20 });
+ // A reopened scene starts with nothing to undo.
+ expect($("scene-undo").disabled).toBe(true);
+ });
+
+ it("undoes with Ctrl/Cmd+Z and redoes with Ctrl/Cmd+Shift+Z or Ctrl+Y, leaving text fields their own undo", async () => {
+ await openBlankScene(content());
+ clickAt(imageNode(), [350, 350]);
+ key("ArrowRight", { shiftKey: true });
+ key("ArrowDown", { shiftKey: true });
+ clickAt(canvasSvg(), [900, 550]);
+ selectAnnotation(1);
+ click("scene-annotation-delete");
+
+ expect(shortcut("z", { ctrlKey: true })).toBe(true);
+ expect(annotations()).toHaveLength(2);
+ expect(shortcut("z", { metaKey: true })).toBe(true);
+ expect(images()[0]).toMatchObject({ x: 300, y: 300 });
+ expect(shortcut("Z", { metaKey: true, shiftKey: true })).toBe(true);
+ expect(images()[0]).toMatchObject({ x: 310, y: 310 });
+ expect(shortcut("y", { ctrlKey: true }, document.body)).toBe(true);
+ expect(annotations()).toHaveLength(1);
+
+ selectAnnotation(0);
+ typeText("Ischium");
+ expect(shortcut("z", { ctrlKey: true }, $("scene-annotation-text"))).toBe(false);
+ expect(annotations()[0].text).toBe("Ischium");
+ });
+
+ it("never undoes into another scene", async () => {
+ backend.seed({ id: "scene-2", name: "Another", ...content() });
+ await openBlankScene(content());
+ selectAnnotation(0);
+ key("ArrowRight");
+
+ jest.spyOn(window, "confirm").mockReturnValue(true);
+ [...document.querySelectorAll(".scene-list-item")].find((b) => b.textContent.includes("Another")).click();
+ await waitFor(() => expect($("scene-title").textContent).toBe("Another"));
+
+ expect($("scene-undo").disabled).toBe(true);
+ expect(shortcut("z", { ctrlKey: true })).toBe(false);
+ expect(annotations()).toEqual(content().annotations);
+ expect(scenesModule.hasUnsavedChanges()).toBe(false);
+ });
+});
+
+describe("Warning about unsaved changes when leaving the editor (#444)", () => {
+ const content = {
+ images: [{ id: "img", src: "/images/a.png", x: 300, y: 300, width: 100, height: 100, source: { type: "legacy-image", boneId: "ilium" } }],
+ annotations: [{ id: "label", type: "text", text: "Ilium", x: 20, y: 20 }],
+ };
+ const inEditor = () => !$("scene-editor-view").hidden;
+ async function openEditedScene() {
+ await openBlankScene(content);
+ selectAnnotation(0);
+ key("ArrowRight");
+ expect(scenesModule.hasUnsavedChanges()).toBe(true);
+ }
+
+ it("lets the user stay in the editor from the Back button, the Scene Editor button, or a viewer link", async () => {
+ await openEditedScene();
+ const confirm = jest.spyOn(window, "confirm").mockReturnValue(false);
+ const shownInViewer = jest.fn();
+ document.addEventListener("bonebox:show-in-viewer", shownInViewer);
+
+ click("scene-editor-back");
+ click("text-button-SceneEditor");
+ document.querySelector(".scene-source-link").click();
+
+ expect(confirm).toHaveBeenCalledTimes(3);
+ expect(confirm).toHaveBeenLastCalledWith(expect.stringContaining("\"Plate\" has unsaved changes"));
+ expect(inEditor()).toBe(true);
+ expect(shownInViewer).not.toHaveBeenCalled();
+ expect(annotations()[0].x).toBe(21);
+ await waitFor(() => expect($("scene-editor-status").textContent).toBe("Still in the Scene Editor. Your changes aren't saved yet."));
+ document.removeEventListener("bonebox:show-in-viewer", shownInViewer);
+ });
+
+ it("leaves when the user chooses to, keeping the unsaved changes for when they come back", async () => {
+ await openEditedScene();
+ jest.spyOn(window, "confirm").mockReturnValue(true);
+
+ click("scene-editor-back");
+ expect(inEditor()).toBe(false);
+
+ click("text-button-SceneEditor");
+ expect(inEditor()).toBe(true);
+ expect(annotations()[0].x).toBe(21);
+ expect(scenesModule.hasUnsavedChanges()).toBe(true);
+ });
+
+ it("leaves without asking when everything is saved", async () => {
+ await openEditedScene();
+ await saveAndWait();
+ const confirm = jest.spyOn(window, "confirm");
+
+ click("scene-editor-back");
+
+ expect(inEditor()).toBe(false);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+});
diff --git a/templates/tests/sceneCanvas.test.js b/templates/tests/sceneCanvas.test.js
index 537c093..8ad6196 100644
--- a/templates/tests/sceneCanvas.test.js
+++ b/templates/tests/sceneCanvas.test.js
@@ -1,4 +1,4 @@
-import { renderScene } from "../js/sceneCanvas.js";
+import { objectsAt, renderScene } from "../js/sceneCanvas.js";
function viewBox(svg) {
const [x, y, width, height] = svg.getAttribute("viewBox").split(" ").map(Number);
@@ -322,3 +322,90 @@ describe("renderScene linked annotations", () => {
expect(annotations).toEqual(before);
});
});
+
+describe("objectsAt (#441)", () => {
+ const at = (scene, point) => objectsAt({ images: [], annotations: [], ...scene }, point);
+ const label = (overrides = {}) => ({ type: "text", text: "Ilium", x: 0, y: 0, fontSize: 20, ...overrides });
+
+ it("lists every object under a point, topmost first: later annotations, then later images", () => {
+ const scene = {
+ images: [image(), image({ x: 50 })],
+ annotations: [
+ label({ x: 60, y: 10 }),
+ { type: "polygon", points: [[40, 0], [160, 0], [160, 90], [40, 90]] },
+ ],
+ };
+ expect(at(scene, [70, 20])).toEqual([
+ { kind: "annotation", index: 1 },
+ { kind: "annotation", index: 0 },
+ { kind: "image", index: 1 },
+ { kind: "image", index: 0 },
+ ]);
+ expect(at(scene, [10, 20])).toEqual([{ kind: "image", index: 0 }]);
+ expect(at(scene, [900, 500])).toEqual([]);
+ });
+
+ it("tests a rotated image against its rotated outline", () => {
+ // 200x100 at the origin turned 90° about (100, 50) covers x 50-150, y -50-150.
+ const scene = { images: [image({ rotation: 90 })] };
+ expect(at(scene, [100, 140])).toEqual([{ kind: "image", index: 0 }]);
+ expect(at(scene, [10, 50])).toEqual([]);
+ });
+
+ it("only counts the visible area of a cropped image, as it is flipped", () => {
+ const cropped = image({ cropX: 0, cropY: 0, cropWidth: 50, cropHeight: 100 });
+ expect(at({ images: [cropped] }, [25, 50])).toEqual([{ kind: "image", index: 0 }]);
+ expect(at({ images: [cropped] }, [150, 50])).toEqual([]);
+ // Flipped horizontally, the visible strip is drawn at the right-hand end.
+ const flipped = { ...cropped, flipX: true };
+ expect(at({ images: [flipped] }, [175, 50])).toEqual([{ kind: "image", index: 0 }]);
+ expect(at({ images: [flipped] }, [25, 50])).toEqual([]);
+ });
+
+ it("hits a line within its click tolerance and a highlight on its fill", () => {
+ const scene = {
+ annotations: [
+ { type: "line", x1: 0, y1: 300, x2: 200, y2: 300 },
+ { type: "polygon", points: [[400, 400], [500, 400], [450, 500]] },
+ ],
+ };
+ expect(at(scene, [100, 305])).toEqual([{ kind: "annotation", index: 0 }]);
+ expect(at(scene, [100, 320])).toEqual([]);
+ expect(at(scene, [450, 450])).toEqual([{ kind: "annotation", index: 1 }]);
+ expect(at(scene, [400, 480])).toEqual([]);
+ });
+
+ it("leaves out objects the canvas can't draw", () => {
+ const scene = {
+ images: [image({ src: "javascript:alert(1)" })],
+ annotations: [{ type: "circle", x: 0, y: 0, r: 50 }, { type: "polygon", points: [[0, 0], [100, 100]] }],
+ };
+ expect(at(scene, [10, 10])).toEqual([]);
+ });
+
+ it("finds a linked annotation where it is drawn, following its moved image", () => {
+ const frame = { x: 0, y: 0, width: 200, height: 100 };
+ const scene = {
+ images: [image({ id: "img", x: 300 })],
+ annotations: [label({ sourceImageId: "img", sourceImageFrame: frame })],
+ };
+ expect(at(scene, [305, 5])).toEqual([{ kind: "annotation", index: 0 }, { kind: "image", index: 0 }]);
+ expect(at(scene, [5, 5])).toEqual([]);
+ });
+
+ it("ignores a colored region outside its image's visible crop", () => {
+ const frame = { x: 0, y: 0, width: 200, height: 100 };
+ const scene = {
+ images: [image({ id: "img", cropX: 0, cropY: 0, cropWidth: 100, cropHeight: 100 })],
+ annotations: [{
+ type: "polygon",
+ points: [[0, 0], [200, 0], [200, 100], [0, 100]],
+ source: { type: "legacy-colored-region" },
+ sourceImageId: "img",
+ sourceImageFrame: frame,
+ }],
+ };
+ expect(at(scene, [50, 50])).toEqual([{ kind: "annotation", index: 0 }, { kind: "image", index: 0 }]);
+ expect(at(scene, [150, 50])).toEqual([]);
+ });
+});
diff --git a/templates/tests/scenes.test.js b/templates/tests/scenes.test.js
index 764f207..7633387 100644
--- a/templates/tests/scenes.test.js
+++ b/templates/tests/scenes.test.js
@@ -489,7 +489,7 @@ describe("Scene editor: importing images - Issue 411", () => {
expect($("scene-image-import-error").textContent).toMatch(/notes\.pdf/);
});
- it("selects an image on click and deselects it on a second click", async () => {
+ it("selects an image on click and keeps it selected on a second click (Issue 440)", async () => {
await openNewScene();
setFiles([{ name: "ilium.png", type: "image/png" }]);
await waitFor(() => expect(document.querySelectorAll(".scene-image-object")).toHaveLength(1));
@@ -498,7 +498,7 @@ describe("Scene editor: importing images - Issue 411", () => {
await waitFor(() => expect(document.querySelectorAll(".scene-selection-outline")).toHaveLength(1));
document.querySelector(".scene-image-object").dispatchEvent(new MouseEvent("click", { bubbles: true }));
- expect(document.querySelectorAll(".scene-selection-outline")).toHaveLength(0);
+ expect(document.querySelectorAll(".scene-selection-outline")).toHaveLength(1);
});
it("clears the selection when clicking empty canvas background", async () => {
@@ -651,7 +651,7 @@ describe("Scene editor: remove and reorder images", () => {
selectImage(0);
expect($("scene-image-toolbar").hidden).toBe(false);
- selectImage(0);
+ $("scene-canvas").dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect($("scene-image-toolbar").hidden).toBe(true);
});
@@ -662,7 +662,6 @@ describe("Scene editor: remove and reorder images", () => {
expect($("scene-image-backward").disabled).toBe(true);
expect($("scene-image-forward").disabled).toBe(false);
- selectImage(0); // deselect
selectImage(1);
expect($("scene-image-forward").disabled).toBe(true);
expect($("scene-image-backward").disabled).toBe(false);