diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa4b9a09..38571f19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented here.
## [unreleased]
+## [0.27.0] - Aug 18th, 2026
+- Hovering a spatial annotation now draws a white outline that hugs its shape.
+- Polish and update the confidence card to include the class name and fix its positioning.
+- Fix shift-hover to properly start a new polygon complex layer.
+- Fix bitmask moves to the image edge that were permanently truncating the mask. Now a translated mask bounces back to its previous position if it would be moved outside the image bounds.
+- Improve read-only subtask handling. Subtasks marked `read_only: true` now actually prevent edits. Additionally, all subtasks may now be read-only (the previous "at least one non-read-only subtask" error has been removed).
+
## [0.26.3] - Aug 13th, 2026
- **Fix wheel-zoom and drag-zoom focal point when the ULabel container is offset from the viewport origin.** `handle_wheel` and `drag_rezoom` used to pass raw `clientX`/`clientY` (viewport coords) straight into `rezoom`, which treats its focal-point arguments as annbox-local. When the container sat at viewport `(0, 0)` the two frames coincided and the bug was invisible; anywhere else (e.g. hosted inside a centered dialog, a padded panel, or below a header) zoom would snap by roughly the annbox's screen offset. Both call sites now convert to annbox-local via `getBoundingClientRect()` before calling `rezoom`, so zoom stays anchored to the cursor / mousedown point regardless of how the host embeds ULabel.
- **New config option `min_zoom_fit_ratio`.** Sets a zoom-out floor as a multiplier of the "whole image just fits the viewport" zoom. `0` (default) preserves the existing behavior (no floor). `1.0` prevents users from zooming out past the fit-to-viewport level. `>1` forces the image to always overflow. Zoom-in is unaffected. The floor recomputes from live annbox dimensions, so it adapts to browser resize.
diff --git a/demo.js b/demo.js
index 34b5a6ca..708b5a5a 100644
--- a/demo.js
+++ b/demo.js
@@ -12,6 +12,7 @@ console.log(`http://localhost:${port}/multi-class.html`);
console.log(`http://localhost:${port}/frames.html`);
console.log(`http://localhost:${port}/box-roi.html`);
console.log(`http://localhost:${port}/resume-from.html`);
+console.log(`http://localhost:${port}/read-only.html`);
console.log(`http://localhost:${port}/row-filtering-example.html`);
console.log(`http://localhost:${port}/bitmask-example.html`);
console.log(`http://localhost:${port}/set-annotations.html`);
diff --git a/demo/read-only.html b/demo/read-only.html
new file mode 100644
index 00000000..cee297a0
--- /dev/null
+++ b/demo/read-only.html
@@ -0,0 +1,163 @@
+
+
+
+
${svg_obj}
@@ -3372,6 +3458,8 @@ export class ULabel {
// Edit suggestion: highlight a point in an annotation that can be edited
show_edit_suggestion(edit_suggestion, currently_exists = false) {
+ // Vertex edit handle is a mutation affordance; skip it in read-only subtasks.
+ if (this.is_current_subtask_read_only()) return;
let esid = "edit_suggestion__" + this.get_current_subtask_key();
var esjq = $("#" + esid);
esjq.css("display", "block");
@@ -3389,6 +3477,46 @@ export class ULabel {
$(".edit_suggestion").css("display", "none");
}
+ is_annotation_hovered(annotation_object) {
+ const subtask_key = this.get_current_subtask_key();
+ return this.subtasks[subtask_key]["state"]["hovered_annid"] === annotation_object["id"];
+ }
+
+ draw_hover_outline(annotation_object, ctx, border_width) {
+ if (!this.is_annotation_hovered(annotation_object)) return;
+ const px_per_px = this.config["px_per_px"];
+ ctx.globalCompositeOperation = "destination-over";
+ ctx.strokeStyle = "white";
+ ctx.lineWidth = border_width * px_per_px;
+ ctx.stroke();
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ set_hovered_annotation(annid) {
+ const subtask_key = this.get_current_subtask_key();
+ const current_subtask = this.subtasks[subtask_key];
+ const prev_annid = current_subtask["state"]["hovered_annid"];
+ if (prev_annid === annid) return;
+
+ // Clear previous hover
+ if (prev_annid !== null) {
+ const prev_ann = current_subtask["annotations"]["access"][prev_annid];
+ if (prev_ann && prev_ann["canvas_id"]) {
+ current_subtask["state"]["hovered_annid"] = null;
+ this.redraw_all_annotations_in_annotation_context(prev_ann["canvas_id"], subtask_key);
+ }
+ }
+
+ // Set and redraw new hover
+ current_subtask["state"]["hovered_annid"] = annid;
+ if (annid !== null) {
+ const ann = current_subtask["annotations"]["access"][annid];
+ if (ann && ann["canvas_id"]) {
+ this.redraw_all_annotations_in_annotation_context(ann["canvas_id"], subtask_key);
+ }
+ }
+ }
+
// Global edit suggestion: id dialog, move button, and delete button
show_global_edit_suggestion(annid, offset = null, nonspatial_id = null) {
const subtask_key = this.get_current_subtask_key();
@@ -3403,18 +3531,29 @@ export class ULabel {
let idd_x;
let idd_y;
+ const is_read_only = this.is_current_subtask_read_only();
if (nonspatial_id === null) {
let esid = "global_edit_suggestion__" + subtask_key;
var esjq = $("#" + esid);
esjq.css("display", "block");
+ // Hide the move/reid/delete buttons on read-only subtasks; the confidence card still shows
+ esjq.find(".global_sub_suggestion").css("display", is_read_only ? "none" : "");
let cbox = current_subtask["annotations"]["access"][annid]["containing_box"];
let new_lft = (cbox["tlx"] + cbox["brx"] + 2 * diffX) / (2 * this.config["image_width"]);
let new_top = (cbox["tly"] + cbox["bry"] + 2 * diffY) / (2 * this.config["image_height"]);
current_subtask["state"]["visible_dialogs"][esid]["left"] = new_lft;
current_subtask["state"]["visible_dialogs"][esid]["top"] = new_top;
+ // Decide confidence card position from the un-offset cbox so it stays stable during moves.
+ // Account for annbox scroll: what matters is the visible position, not the image-space position.
+ const cbox_center_y_imwrap = ((cbox["tly"] + cbox["bry"]) / 2) * this.state["zoom_val"];
+ const scroll_top = $("#" + this.config["annbox_id"]).scrollTop() || 0;
+ const conf_id = `global_annotation_confidence__${subtask_key}`;
+ const flip_below = (cbox_center_y_imwrap - scroll_top) < 100;
+ $(`#${conf_id}`).css("margin-top", flip_below ? "-1em" : "-9.5em");
this.reposition_dialogs();
idd_x = (cbox["tlx"] + cbox["brx"] + 2 * diffX) / 2;
idd_y = (cbox["tly"] + cbox["bry"] + 2 * diffY) / 2;
+ this.set_hovered_annotation(annid);
} else {
// TODO(new3d)
idd_x = $("#reclf__" + nonspatial_id).offset().left - 85;// this.get_global_element_center_x($("#reclf__" + nonspatial_id));
@@ -3422,7 +3561,7 @@ export class ULabel {
}
// let placeholder = $("#global_edit_suggestion a.reid_suggestion");
- if (!current_subtask["single_class_mode"]) {
+ if (!current_subtask["single_class_mode"] && !is_read_only) {
// Show id dialog thumbnail
this.show_id_dialog(idd_x, idd_y, annid, true, nonspatial_id != null);
}
@@ -3431,6 +3570,7 @@ export class ULabel {
hide_global_edit_suggestion() {
$(".global_edit_suggestion").css("display", "none");
this.hide_id_dialog();
+ this.set_hovered_annotation(null);
}
// ID dialog: color wheel to change the ID of an annotation
@@ -5748,6 +5888,7 @@ export class ULabel {
const diffZ = this.state["current_frame"] - this.drag_state["move"]["mouse_start"][2];
const current_subtask = this.get_current_subtask();
+ const current_subtask_key = this.get_current_subtask_key();
const active_id = current_subtask["state"]["active_id"];
const annotation = current_subtask["annotations"]["access"][active_id];
const spatial_type = annotation["spatial_type"];
@@ -5756,7 +5897,33 @@ export class ULabel {
// Bitmask masks are translated wholesale rather than point-by-point
if (spatial_type === "bitmask") {
+ // Bitmask storage is bounded by image dimensions; a translate that pushes
+ // pixels outside would silently drop them, so always bounce back in that case.
+ const cbox = annotation["containing_box"];
+ const idx = Math.round(diffX);
+ const idy = Math.round(diffY);
+ const bmask_out_of_bounds = cbox != null && (
+ cbox["tlx"] + idx < 0 ||
+ cbox["tly"] + idy < 0 ||
+ cbox["brx"] + idx > this.config["image_width"] - 1 ||
+ cbox["bry"] + idy > this.config["image_height"] - 1
+ );
+
+ if (bmask_out_of_bounds) {
+ current_subtask["state"]["active_id"] = null;
+ current_subtask["state"]["is_in_move"] = false;
+ this.end_bitmask_move();
+ // Redraw the mask at its original position (overlay was showing offset position)
+ this.redraw_all_annotations_in_annotation_context(annotation["canvas_id"], current_subtask_key);
+ record_finish_move(this, diffX, diffY, diffZ, true);
+ // Pop the begin_move off the stream so Ctrl+Z won't try to reverse a move that didn't happen
+ undo(this, true);
+ this.shake_screen();
+ return;
+ }
+
this.translate_bitmask(annotation, diffX, diffY);
+ this.rebuild_bitmask_containing_box(annotation);
current_subtask["state"]["active_id"] = null;
current_subtask["state"]["is_in_move"] = false;
this.end_bitmask_move();
@@ -5835,7 +6002,10 @@ export class ULabel {
// Bitmask masks are translated wholesale rather than point-by-point
if (spatial_type === "bitmask") {
+ // If the forward move was bounced back, nothing to reverse
+ if (undo_payload.move_not_allowed) return;
this.translate_bitmask(annotations[annotation_id], diffX, diffY);
+ this.rebuild_bitmask_containing_box(annotations[annotation_id]);
return;
}
@@ -5883,6 +6053,7 @@ export class ULabel {
// Bitmask masks are translated wholesale rather than point-by-point
if (spatial_type === "bitmask") {
this.translate_bitmask(annotations[annotation_id], diffX, diffY);
+ this.rebuild_bitmask_containing_box(annotations[annotation_id]);
} else {
// if a polygon, n_iters is the length the spatial payload
// else n_iters is 1
@@ -6299,7 +6470,17 @@ export class ULabel {
if (annid != null) {
let anpyld = this.get_current_subtask()["annotations"]["access"][annid]["classification_payloads"];
if (anpyld != null) {
- this.get_current_subtask()["state"]["id_payload"] = JSON.parse(JSON.stringify(anpyld));
+ // Normalize to one entry per class_id so update_id_dialog_display
+ // can index by position without out-of-bounds access.
+ const class_ids = this.get_current_subtask()["class_ids"];
+ const padded = class_ids.map((cid) => {
+ const existing = anpyld.find((p) => p.class_id === cid);
+ if (existing) {
+ return JSON.parse(JSON.stringify(existing));
+ }
+ return { class_id: cid, confidence: 0 };
+ });
+ this.get_current_subtask()["state"]["id_payload"] = padded;
return;
}
}
@@ -6494,21 +6675,39 @@ export class ULabel {
update_confidence_dialog() {
// Whenever the mouse makes the dialogs show up, update the displayed annotation confidence.
const current_subtask = this.get_current_subtask();
+ const subtask_key = this.get_current_subtask_key();
const active_annotation_id = current_subtask["state"]["edit_candidate"]["annid"];
const active_annotation = current_subtask["annotations"]["access"][active_annotation_id];
/** The active annotation's classification payloads. */
const aacp = active_annotation["classification_payloads"];
- // Keep track of highest payload confidence
- let confidence = 0;
+ // Match get_annotation_class_id semantics: seed the first payload so all-zero confidences
+ // still pick a class instead of falling through to "Unknown".
+ let confidence;
+ let best_class_id = null;
aacp.forEach((payload) => {
- if (payload.confidence > confidence) {
+ if (confidence === undefined || payload.confidence > confidence) {
confidence = payload.confidence;
+ best_class_id = payload.class_id;
}
});
+ if (confidence === undefined) confidence = 0;
+
+ // Resolve class name from class_defs
+ let class_name = "Unknown";
+ if (best_class_id !== null) {
+ const class_def = current_subtask["class_defs"].find(
+ (cd) => cd.id === best_class_id || String(cd.id) === String(best_class_id),
+ );
+ if (class_def) {
+ class_name = class_def.name;
+ }
+ }
- // Update the display dialog with the annotation's confidence
- $(".annotation-confidence-value").text(confidence);
+ // Update the display dialog
+ const global_id = `global_annotation_confidence__${subtask_key}`;
+ $(`#${global_id} .annotation-confidence-classname`).text(class_name);
+ $(`#${global_id} .annotation-confidence-value`).text(`Confidence: ${confidence.toFixed(2)}`);
}
// ================= Viewer/Annotation Interaction Handlers =================
diff --git a/src/listeners.ts b/src/listeners.ts
index e22c0b09..83d3ddf5 100644
--- a/src/listeners.ts
+++ b/src/listeners.ts
@@ -66,9 +66,11 @@ function handle_keypress_event(
}
const current_subtask = ulabel.get_current_subtask();
+ const is_read_only = ulabel.is_current_subtask_read_only();
// Create a point annotation at the mouse's current location
if (event_matches_keybind(keypress_event, ulabel.config.create_point_annotation_keybind)) {
+ if (is_read_only) return;
// Only allow keypress to create point annotations
if (current_subtask.state.annotation_mode === "point") {
// Create an annotation based on the last mouse position
@@ -80,6 +82,7 @@ function handle_keypress_event(
// Create a bbox annotation around the initial_crop,
// or the whole image if inital_crop does not exist
if (event_matches_keybind(keypress_event, ulabel.config.create_bbox_on_initial_crop_keybind)) {
+ if (is_read_only) return;
if (current_subtask.state.annotation_mode === "bbox") {
// Default to an annotation with size of image
// Create the coordinates for the bbox's spatial payload
@@ -111,12 +114,14 @@ function handle_keypress_event(
// Change to brush mode (for now, polygon only)
if (event_matches_keybind(keypress_event, ulabel.config.toggle_brush_mode_keybind)) {
+ if (is_read_only) return;
ulabel.toggle_brush_mode(ulabel.state["last_move"]);
return;
}
// Change to erase mode (will also set the is_in_brush_mode state)
if (event_matches_keybind(keypress_event, ulabel.config.toggle_erase_mode_keybind)) {
+ if (is_read_only) return;
ulabel.toggle_erase_mode(ulabel.state["last_move"]);
return;
}
@@ -164,6 +169,7 @@ function handle_keypress_event(
for (let i = 0; i < current_subtask.class_defs.length; i++) {
const class_def = current_subtask.class_defs[i];
if (class_def.keybind !== null && event_matches_keybind(keypress_event, class_def.keybind!)) {
+ if (is_read_only) return;
const st_key = ulabel.get_current_subtask_key();
const class_button = $(`#tb-id-app--${st_key} a.tbid-opt`).eq(i);
if (class_button.hasClass("sel")) {
@@ -223,8 +229,8 @@ function handle_soft_id_toolbox_button_click(
ulabel.update_id_dialog_display();
// Update the class of the active annotation,
- // except when toggling on the delete class
- if (rawid !== DELETE_CLASS_ID) {
+ // except when toggling on the delete class or in a read-only subtask
+ if (rawid !== DELETE_CLASS_ID && !ulabel.is_current_subtask_read_only()) {
// Get the active annotation, if any
let target_id = null;
if (current_subtask.state.active_id !== null) {
@@ -635,14 +641,17 @@ export function create_ulabel_listeners(
(keypress_event: JQuery.KeyPressEvent) => {
// Check the key pressed against the delete annotation keybind in the config
if (event_matches_keybind(keypress_event, ulabel.config.delete_annotation_keybind)) {
+ if (ulabel.is_current_subtask_read_only()) return;
+ const current_subtask = ulabel.get_current_subtask();
// Check the edit_candidate to make sure its not null and isn't nonspatial
- const edit_cand = ulabel.get_current_subtask().state.edit_candidate;
+ const edit_cand = current_subtask.state.edit_candidate;
if (edit_cand !== null && !NONSPATIAL_MODES.includes(edit_cand.spatial_type)) {
ulabel.delete_annotation(edit_cand.annid);
}
}
// Check the key pressed against the delete vertex keybind in the config
if (event_matches_keybind(keypress_event, ulabel.config.delete_vertex_keybind)) {
+ if (ulabel.is_current_subtask_read_only()) return;
const current_subtask = ulabel.get_current_subtask();
const edit_cand = current_subtask.state.edit_candidate;
diff --git a/src/subtask.ts b/src/subtask.ts
index a106fb03..a43a955c 100644
--- a/src/subtask.ts
+++ b/src/subtask.ts
@@ -30,6 +30,7 @@ export class ULabelSubtask {
move_candidate: ULabelActionCandidate | null;
first_explicit_assignment: boolean;
front_context: CanvasRenderingContext2D;
+ hovered_annid: string | null;
id_payload: number[] | {
class_id: number;
confidence: number;
diff --git a/src/version.js b/src/version.js
index 84e4b0da..9b3577eb 100644
--- a/src/version.js
+++ b/src/version.js
@@ -1 +1 @@
-export const ULABEL_VERSION = "0.26.3";
+export const ULABEL_VERSION = "0.27.0";
diff --git a/tests/e2e/basic-functionality.spec.js b/tests/e2e/basic-functionality.spec.js
index c67dc2b6..1f2165ed 100644
--- a/tests/e2e/basic-functionality.spec.js
+++ b/tests/e2e/basic-functionality.spec.js
@@ -105,4 +105,194 @@ test.describe("ULabel Basic Functionality", () => {
expect(anno.spatial_payload).toEqual(point);
expect(anno.created_by).toBe("DemoUser");
});
+
+ test("hovered_annid tracks the annotation under the cursor", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ await draw_bbox(page, [200, 200], [300, 300]);
+ await page.waitForTimeout(100);
+
+ const initial_hovered = await page.evaluate(() => window.ulabel.get_current_subtask().state.hovered_annid);
+ expect(initial_hovered).toBeNull();
+
+ // Hover over the bbox
+ await page.mouse.move(250, 250);
+ await page.waitForTimeout(200);
+
+ const hovered_over_bbox = await page.evaluate(() => {
+ const st = window.ulabel.get_current_subtask();
+ const annid = st.state.hovered_annid;
+ return {
+ hovered_annid: annid,
+ matches_annotation: annid !== null && annid === st.annotations.ordering[0],
+ };
+ });
+ expect(hovered_over_bbox.hovered_annid).not.toBeNull();
+ expect(hovered_over_bbox.matches_annotation).toBe(true);
+
+ // Move cursor away from the annotation
+ await page.mouse.move(50, 50);
+ await page.waitForTimeout(200);
+
+ const hovered_after_leave = await page.evaluate(() => window.ulabel.get_current_subtask().state.hovered_annid);
+ expect(hovered_after_leave).toBeNull();
+ });
+
+ test("confidence card shows class name and confidence value on hover", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ await draw_bbox(page, [200, 200], [300, 300]);
+ await page.waitForTimeout(100);
+
+ // Hover to display the confidence card
+ await page.mouse.move(250, 250);
+ await page.waitForTimeout(200);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const conf_id = `#global_annotation_confidence__${subtask_key}`;
+
+ const classname = (await page.locator(`${conf_id} .annotation-confidence-classname`).textContent()).trim();
+ const value = (await page.locator(`${conf_id} .annotation-confidence-value`).textContent()).trim();
+
+ // First class in multi-class.html's car_detection subtask is "Sedan"
+ expect(classname).toBe("Sedan");
+ // Manually-drawn annotations get confidence 1
+ expect(value).toBe("Confidence: 1.00");
+ });
+
+ test("confidence card flips below buttons when annotation is near the top of the image", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const conf_id = `#global_annotation_confidence__${subtask_key}`;
+
+ // Annotation well away from the top -> card above the buttons (default -9.5em)
+ await draw_bbox(page, [400, 400], [500, 500]);
+ await page.waitForTimeout(100);
+ await page.mouse.move(450, 450);
+ await page.waitForTimeout(200);
+
+ const margin_below_center = await page.locator(conf_id).evaluate((el) => el.style.marginTop);
+ expect(margin_below_center).toBe("-9.5em");
+
+ // Annotation near the top edge -> card flips below the buttons
+ await draw_bbox(page, [100, 5], [200, 30]);
+ await page.waitForTimeout(100);
+ await page.mouse.move(150, 15);
+ await page.waitForTimeout(200);
+
+ const margin_near_top = await page.locator(conf_id).evaluate((el) => el.style.marginTop);
+ expect(margin_near_top).toBe("-1em");
+ });
+
+ test("confidence card flip check accounts for annbox scroll position", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const conf_id = `#global_annotation_confidence__${subtask_key}`;
+
+ // Upper-middle annotation displays with card above (no flip)
+ await draw_bbox(page, [400, 200], [500, 300]);
+ await page.waitForTimeout(100);
+ await page.mouse.move(450, 250);
+ await page.waitForTimeout(200);
+
+ const margin_unscrolled = await page.locator(conf_id).evaluate((el) => el.style.marginTop);
+ expect(margin_unscrolled).toBe("-9.5em");
+
+ // Zoom in enough for the imwrap to overflow the annbox so scrolling is possible
+ await page.mouse.move(450, 250);
+ for (let i = 0; i < 10; i++) {
+ await page.mouse.wheel(0, -100);
+ }
+ await page.waitForTimeout(300);
+
+ // Scroll the annbox so the annotation is near the top of the visible area
+ const scroll_result = await page.evaluate(() => {
+ const u = window.ulabel;
+ const annbox = document.getElementById(u.config.annbox_id);
+ const annid = u.get_current_subtask().annotations.ordering[0];
+ const cbox = u.get_current_subtask().annotations.access[annid].containing_box;
+ const cbox_y_scaled = ((cbox.tly + cbox.bry) / 2) * u.state.zoom_val;
+ annbox.scrollTop = cbox_y_scaled;
+ u.get_current_subtask().state.edit_candidate = { annid: annid };
+ u.show_global_edit_suggestion(annid);
+ return { scroll_top: annbox.scrollTop, cbox_y_scaled: cbox_y_scaled };
+ });
+ // Sanity: scroll happened and wasn't clamped far from the target
+ expect(scroll_result.scroll_top).toBeGreaterThan(0);
+ expect(scroll_result.scroll_top).toBeGreaterThanOrEqual(scroll_result.cbox_y_scaled - 100);
+ await page.waitForTimeout(100);
+
+ const margin_scrolled = await page.locator(conf_id).evaluate((el) => el.style.marginTop);
+ expect(margin_scrolled).toBe("-1em");
+ });
+
+ test("confidence card picks a class name even when all confidences are 0", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ // Draw a bbox using page coordinates (draw_bbox handles image-space mapping)
+ await draw_bbox(page, [200, 200], [300, 300]);
+ await page.waitForTimeout(100);
+
+ // Force a single classification payload with confidence 0 (represents an all-zero import;
+ // annotation.ts pads missing classes with 0.0, so this is a realistic scenario).
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ const annid = u.get_current_subtask().annotations.ordering[0];
+ const anno = u.get_current_subtask().annotations.access[annid];
+ anno.classification_payloads = [{ class_id: 10, confidence: 0 }];
+ });
+
+ // Trigger the confidence display programmatically
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ const subtask = u.get_current_subtask();
+ const annid = subtask.annotations.ordering[0];
+ subtask.state.edit_candidate = { annid: annid };
+ u.show_global_edit_suggestion(annid);
+ u.update_confidence_dialog();
+ });
+ await page.waitForTimeout(100);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const conf_id = `#global_annotation_confidence__${subtask_key}`;
+ const classname = (await page.locator(`${conf_id} .annotation-confidence-classname`).textContent()).trim();
+ const value = (await page.locator(`${conf_id} .annotation-confidence-value`).textContent()).trim();
+
+ // Pre-fix: classname would be "Unknown" because the > 0 check never matched
+ expect(classname).toBe("Sedan");
+ expect(value).toBe("Confidence: 0.00");
+ });
+
+ test("switching subtasks clears hovered_annid on the outgoing subtask", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ await draw_bbox(page, [200, 200], [300, 300]);
+ await page.waitForTimeout(100);
+
+ // Hover to set hovered_annid on the current subtask
+ await page.mouse.move(250, 250);
+ await page.waitForTimeout(200);
+
+ const before = await page.evaluate(() => {
+ const u = window.ulabel;
+ const key = u.get_current_subtask_key();
+ return {
+ key: key,
+ hovered_annid: u.subtasks[key].state.hovered_annid,
+ };
+ });
+ expect(before.hovered_annid).not.toBeNull();
+
+ // Switch to the next subtask
+ await page.evaluate(() => window.ulabel.switch_to_next_subtask());
+ await page.waitForTimeout(100);
+
+ // The previous subtask's hovered_annid must be cleared so its stale outline doesn't linger
+ const after = await page.evaluate((old_key) => {
+ return window.ulabel.subtasks[old_key].state.hovered_annid;
+ }, before.key);
+ expect(after).toBeNull();
+ });
});
diff --git a/tests/e2e/bitmask.spec.js b/tests/e2e/bitmask.spec.js
index 48a14e66..148788f3 100644
--- a/tests/e2e/bitmask.spec.js
+++ b/tests/e2e/bitmask.spec.js
@@ -352,3 +352,146 @@ test.describe("Bitmask overlap + move", () => {
expect(res.active_kept_outside).toBe(1);
});
});
+
+// End-to-end coverage for the bitmask move bounce-back:
+// - a move that would push any pixel outside the image is rejected (mask unchanged)
+// - the rejected move is popped off the action stream, so subsequent undo/redo is a no-op
+// - a valid move round-trips cleanly through undo and redo
+test.describe("Bitmask move bounce-back", () => {
+ // Simulate begin_move: push the begin_move action and prime state/drag_state/overlay.
+ // A no-op object works here — finish_move only reads clientX/clientY.
+ async function setup_move(page, tlx, tly, brx, bry) {
+ await page.evaluate(async (box) => {
+ const u = window.ulabel;
+ const { make, rebuild } = window.__mask_helpers(u);
+ await u.set_annotations([make("m", 1, box.tlx, box.tly, box.brx, box.bry)], "a");
+ rebuild("a", "m");
+ u.set_subtask("a");
+
+ const st = u.subtasks.a;
+ st.actions.stream = [];
+ st.actions.undone_stack = [];
+ st.state.active_id = "m";
+ st.state.is_in_move = true;
+ u.state.zoom_val = 1.0;
+ u.state.current_frame = 0;
+ u.drag_state.move.mouse_start = [100, 100, 0];
+
+ st.actions.stream.push({
+ act_type: "begin_move",
+ annotation_id: "m",
+ frame: 0,
+ undo_payload: JSON.stringify({ diffX: 0, diffY: 0, diffZ: 0 }),
+ redo_payload: JSON.stringify({ diffX: 0, diffY: 0, diffZ: 0, finished: false, move_not_allowed: false }),
+ prev_timestamp: null,
+ prev_user: "test",
+ });
+ u.begin_bitmask_move("m", "a");
+ }, { tlx, tly, brx, bry });
+ }
+
+ test("out-of-bounds move is rejected: mask stays put and action is popped off the stream", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+ // Mask hugs the left edge; a -50px shift would drop pixels off-image.
+ await setup_move(page, 0, 0, 10, 10);
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { pix } = window.__mask_helpers(u);
+
+ u.finish_move({ clientX: 50, clientY: 100 });
+
+ const st = u.subtasks.a;
+ return {
+ pixel_at_5_5: pix("a", "m", 5, 5),
+ cbox: JSON.parse(JSON.stringify(u.subtasks.a.annotations.access.m.containing_box)),
+ is_in_move: st.state.is_in_move,
+ active_id: st.state.active_id,
+ stream_len: st.actions.stream.length,
+ undone_len: st.actions.undone_stack.length,
+ overlay_cleared: u.state.bitmask_move_overlay == null,
+ };
+ });
+
+ expect(res.pixel_at_5_5).toBe(1);
+ expect(res.cbox).toEqual({ tlx: 0, tly: 0, brx: 10, bry: 10 });
+ expect(res.is_in_move).toBe(false);
+ expect(res.active_id).toBeNull();
+ expect(res.stream_len).toBe(0);
+ expect(res.undone_len).toBe(1);
+ expect(res.overlay_cleared).toBe(true);
+ });
+
+ test("undo after a bounce-back does not move the mask", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+ await setup_move(page, 0, 0, 10, 10);
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { pix } = window.__mask_helpers(u);
+
+ u.finish_move({ clientX: 50, clientY: 100 });
+ u.undo();
+ u.redo();
+
+ return {
+ pixel_at_5_5: pix("a", "m", 5, 5),
+ pixel_at_0_0: pix("a", "m", 0, 0),
+ pixel_at_10_10: pix("a", "m", 10, 10),
+ cbox: JSON.parse(JSON.stringify(u.subtasks.a.annotations.access.m.containing_box)),
+ };
+ });
+
+ expect(res.pixel_at_5_5).toBe(1);
+ expect(res.pixel_at_0_0).toBe(1);
+ expect(res.pixel_at_10_10).toBe(1);
+ expect(res.cbox).toEqual({ tlx: 0, tly: 0, brx: 10, bry: 10 });
+ });
+
+ test("valid move round-trips through undo and redo", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+ // Mask well inside the image so a +5px shift is safe.
+ await setup_move(page, 10, 10, 20, 20);
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { pix } = window.__mask_helpers(u);
+
+ // +5 px in X: (10..20, 10..20) -> (15..25, 10..20)
+ u.finish_move({ clientX: 105, clientY: 100 });
+ const after_move = {
+ orig_edge_empty: pix("a", "m", 10, 15),
+ new_edge_full: pix("a", "m", 25, 15),
+ cbox: JSON.parse(JSON.stringify(u.subtasks.a.annotations.access.m.containing_box)),
+ };
+
+ u.undo();
+ const after_undo = {
+ orig_edge_full: pix("a", "m", 10, 15),
+ new_edge_empty: pix("a", "m", 25, 15),
+ cbox: JSON.parse(JSON.stringify(u.subtasks.a.annotations.access.m.containing_box)),
+ };
+
+ u.redo();
+ const after_redo = {
+ orig_edge_empty: pix("a", "m", 10, 15),
+ new_edge_full: pix("a", "m", 25, 15),
+ cbox: JSON.parse(JSON.stringify(u.subtasks.a.annotations.access.m.containing_box)),
+ };
+
+ return { after_move, after_undo, after_redo };
+ });
+
+ expect(res.after_move.orig_edge_empty).toBe(0);
+ expect(res.after_move.new_edge_full).toBe(1);
+ expect(res.after_move.cbox).toEqual({ tlx: 15, tly: 10, brx: 25, bry: 20 });
+
+ expect(res.after_undo.orig_edge_full).toBe(1);
+ expect(res.after_undo.new_edge_empty).toBe(0);
+ expect(res.after_undo.cbox).toEqual({ tlx: 10, tly: 10, brx: 20, bry: 20 });
+
+ expect(res.after_redo.orig_edge_empty).toBe(0);
+ expect(res.after_redo.new_edge_full).toBe(1);
+ expect(res.after_redo.cbox).toEqual({ tlx: 15, tly: 10, brx: 25, bry: 20 });
+ });
+});
diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js
index 1f66ad77..b481e073 100644
--- a/tests/e2e/keybind-functionality.spec.js
+++ b/tests/e2e/keybind-functionality.spec.js
@@ -808,4 +808,57 @@ test.describe("Keybind Functionality Tests", () => {
annotation = await get_annotation_by_index(page, 0);
expect(annotation.deprecated).toBe(true);
});
+
+ test("shift-hover on an existing polygon starts a new complex layer", async ({ page }) => {
+ await wait_for_ulabel_init(page);
+
+ // Draw a polygon large enough to hover safely inside
+ await draw_polygon(page, [
+ [200, 200],
+ [400, 200],
+ [400, 400],
+ [200, 400],
+ ]);
+ await page.waitForTimeout(200);
+
+ // Sanity: one annotation, one layer
+ let annotation = await get_annotation_by_index(page, 0);
+ expect(annotation.spatial_payload.length).toBe(1);
+
+ // Prime the id dialog / edit_candidate by hovering without shift
+ await page.mouse.move(300, 300);
+ await page.waitForTimeout(200);
+
+ // Shift-hover should trigger start_complex_polygon (bug pre-fix: no-op)
+ await page.keyboard.down("Shift");
+ await page.mouse.move(305, 305);
+ await page.waitForTimeout(200);
+
+ const hover_state = await page.evaluate(() => {
+ const st = window.ulabel.get_current_subtask().state;
+ return {
+ starting_complex_polygon: st.starting_complex_polygon,
+ is_in_progress: st.is_in_progress,
+ };
+ });
+ expect(hover_state.starting_complex_polygon).toBe(true);
+ expect(hover_state.is_in_progress).toBe(true);
+
+ // With shift still held, mousedown must be an "annotation" drag, not "zoom" (the bug we fixed)
+ await page.mouse.down({ button: "left" });
+ const drag_key = await page.evaluate(() => window.ulabel.drag_state.active_key);
+ expect(drag_key).toBe("annotation");
+ await page.mouse.up({ button: "left" });
+ await page.keyboard.up("Shift");
+
+ // Finish the new layer by clicking a couple more points and the ender
+ await page.mouse.click(320, 305);
+ await page.mouse.click(320, 320);
+ await page.click(".ender_outer");
+ await page.waitForTimeout(200);
+
+ // The annotation should now have two layers (original + new complex layer)
+ annotation = await get_annotation_by_index(page, 0);
+ expect(annotation.spatial_payload.length).toBe(2);
+ });
});
diff --git a/tests/e2e/read-only.spec.js b/tests/e2e/read-only.spec.js
new file mode 100644
index 00000000..bc88ab48
--- /dev/null
+++ b/tests/e2e/read-only.spec.js
@@ -0,0 +1,250 @@
+// End-to-end tests for read-only subtask behavior against demo/read-only.html.
+// All subtasks in that demo are marked read_only: true, so no user-driven mutations
+// should be possible; hover viewing (confidence card, hover outline) is preserved.
+import { test, expect } from "./fixtures";
+import { wait_for_ulabel_init } from "../testing-utils/init_utils";
+import { get_annotation_count } from "../testing-utils/annotation_utils";
+import { switch_to_subtask } from "../testing-utils/subtask_utils";
+
+test.describe("Read-only subtask behavior", () => {
+ test("loads with every subtask marked read_only without erroring", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ const flags = await page.evaluate(() => {
+ const u = window.ulabel;
+ return {
+ is_init: u.is_init,
+ car_ro: u.subtasks.car_detection.read_only,
+ fr_ro: u.subtasks.frame_review.read_only,
+ };
+ });
+ expect(flags.is_init).toBe(true);
+ expect(flags.car_ro).toBe(true);
+ expect(flags.fr_ro).toBe(true);
+ });
+
+ test("global edit suggestion hides move/reid/delete buttons and skips id dialog thumbnail", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Trigger the edit suggestion directly for a known bbox annotation
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ const annid = "ro-bbox-1";
+ u.get_current_subtask().state.edit_candidate = { annid: annid };
+ u.show_global_edit_suggestion(annid);
+ });
+ await page.waitForTimeout(100);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const global_id = `#global_edit_suggestion__${subtask_key}`;
+
+ // Container itself is displayed (so the confidence card can render)
+ const container_display = await page.locator(global_id).evaluate((el) => el.style.display);
+ expect(container_display).toBe("block");
+
+ // All action buttons inside are display:none
+ const button_displays = await page.locator(`${global_id} .global_sub_suggestion`).evaluateAll(
+ (els) => els.map((el) => el.style.display),
+ );
+ expect(button_displays.length).toBeGreaterThan(0);
+ for (const d of button_displays) expect(d).toBe("none");
+
+ // ID dialog thumbnail is not shown
+ const idd_visible = await page.evaluate(() => window.ulabel.get_current_subtask().state.idd_visible);
+ expect(idd_visible).toBe(false);
+ });
+
+ test("confidence card still renders on hover in read-only mode", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ const annid = "ro-bbox-1";
+ u.get_current_subtask().state.edit_candidate = { annid: annid };
+ u.show_global_edit_suggestion(annid);
+ u.update_confidence_dialog();
+ });
+ await page.waitForTimeout(100);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const conf_id = `#global_annotation_confidence__${subtask_key}`;
+ const classname = (await page.locator(`${conf_id} .annotation-confidence-classname`).textContent()).trim();
+ const value = (await page.locator(`${conf_id} .annotation-confidence-value`).textContent()).trim();
+
+ expect(classname).toBe("Sedan");
+ expect(value).toBe("Confidence: 0.82");
+ });
+
+ test("vertex edit handle (show_edit_suggestion) is suppressed in read-only mode", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Direct invocation with a plausible vertex candidate; the method should early-return
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ u.show_edit_suggestion({ annid: "ro-polygon-1", point: [1073.79, 444.87] }, true);
+ });
+ await page.waitForTimeout(50);
+
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ const edit_suggestion_display = await page.locator(`#edit_suggestion__${subtask_key}`).evaluate((el) => el.style.display);
+ // The default display starts empty (never shown), which is what we want
+ expect(edit_suggestion_display).not.toBe("block");
+ });
+
+ test("hovered_annid still tracks the annotation for the hover outline", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ u.get_current_subtask().state.edit_candidate = { annid: "ro-bbox-1" };
+ u.show_global_edit_suggestion("ro-bbox-1");
+ });
+ await page.waitForTimeout(50);
+
+ const hovered = await page.evaluate(() => window.ulabel.get_current_subtask().state.hovered_annid);
+ expect(hovered).toBe("ro-bbox-1");
+ });
+
+ test("canvas mousedown does not begin a new annotation in read-only mode", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ const initial_count = await get_annotation_count(page);
+
+ // Simulate a mousedown on the front canvas at a location with no existing annotation
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ const canvas = document.getElementById(u.get_current_subtask().canvas_fid);
+ const evt = new MouseEvent("mousedown", { button: 0, clientX: 250, clientY: 250, bubbles: true });
+ Object.defineProperty(evt, "target", { value: canvas });
+ const drag_key = window.ULabel.get_drag_key_start(evt, u);
+ return drag_key;
+ });
+
+ // No new annotation should exist
+ const after_count = await get_annotation_count(page);
+ expect(after_count).toBe(initial_count);
+
+ // Drag key returned should be null for a plain canvas click in read-only
+ const drag_key = await page.evaluate(() => {
+ const u = window.ulabel;
+ const canvas = document.getElementById(u.get_current_subtask().canvas_fid);
+ const evt = new MouseEvent("mousedown", { button: 0, clientX: 250, clientY: 250, bubbles: true });
+ Object.defineProperty(evt, "target", { value: canvas });
+ return window.ULabel.get_drag_key_start(evt, u);
+ });
+ expect(drag_key).toBeNull();
+ });
+
+ test("delete_annotation call is blocked by public delete keybind path", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Set the hovered annotation as an edit_candidate (as suggest_edits would)
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ u.get_current_subtask().state.edit_candidate = {
+ annid: "ro-bbox-1",
+ spatial_type: "bbox",
+ };
+ });
+
+ // Simulate the delete keybind by pressing 'd' (default per config)
+ const initial_deprecated = await page.evaluate(() => {
+ return window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"].deprecated;
+ });
+ expect(initial_deprecated).toBe(false);
+
+ await page.keyboard.press("d");
+ await page.waitForTimeout(100);
+
+ const still_present = await page.evaluate(() => {
+ const anno = window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"];
+ return { exists: anno != null, deprecated: anno.deprecated };
+ });
+ expect(still_present.exists).toBe(true);
+ expect(still_present.deprecated).toBe(false);
+ });
+
+ test("class keybind does not reassign a hovered annotation's class", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Prime hover state
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ u.get_current_subtask().state.edit_candidate = {
+ annid: "ro-bbox-1",
+ spatial_type: "bbox",
+ };
+ u.get_current_subtask().state.move_candidate = { annid: "ro-bbox-1" };
+ });
+
+ const before_class = await page.evaluate(() => {
+ const anno = window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"];
+ return anno.classification_payloads.map((p) => ({ class_id: p.class_id, confidence: p.confidence }));
+ });
+
+ // Press '2' (SUV keybind) — pre-fix, this could reassign class of hovered annotation
+ await page.keyboard.press("2");
+ await page.waitForTimeout(100);
+
+ const after_class = await page.evaluate(() => {
+ const anno = window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"];
+ return anno.classification_payloads.map((p) => ({ class_id: p.class_id, confidence: p.confidence }));
+ });
+ expect(after_class).toEqual(before_class);
+ });
+
+ test("clicking a class button does not reassign a hovered annotation's class", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Prime hover state so the class-button click handler would target this annotation
+ await page.evaluate(() => {
+ const u = window.ulabel;
+ u.get_current_subtask().state.edit_candidate = {
+ annid: "ro-bbox-1",
+ spatial_type: "bbox",
+ };
+ u.get_current_subtask().state.move_candidate = { annid: "ro-bbox-1" };
+ });
+
+ const before = await page.evaluate(() => {
+ const anno = window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"];
+ return anno.classification_payloads.map((p) => ({ class_id: p.class_id, confidence: p.confidence }));
+ });
+
+ // Click a different class button in the toolbox (SUV, index 1)
+ const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key());
+ await page.locator(`#tb-id-app--${subtask_key} a.tbid-opt`).nth(1).click();
+ await page.waitForTimeout(100);
+
+ const after = await page.evaluate(() => {
+ const anno = window.ulabel.subtasks.car_detection.annotations.access["ro-bbox-1"];
+ return anno.classification_payloads.map((p) => ({ class_id: p.class_id, confidence: p.confidence }));
+ });
+ expect(after).toEqual(before);
+ });
+
+ test("nonspatial annotation row has no reclassify or delete buttons and a readonly note", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/read-only.html");
+
+ // Second subtask (frame_review) has a whole-image annotation
+ await switch_to_subtask(page, 1);
+ await page.waitForTimeout(200);
+
+ const row_state = await page.evaluate(() => {
+ const annid = "ro-whole-image-1";
+ const note = document.getElementById("note__" + annid);
+ const reclf = document.getElementById("reclf__" + annid);
+ const del = document.getElementById("delete__" + annid);
+ return {
+ note_exists: note != null,
+ note_readonly: note != null && note.hasAttribute("readonly"),
+ reclf_exists: reclf != null,
+ delete_exists: del != null,
+ };
+ });
+ expect(row_state.note_exists).toBe(true);
+ expect(row_state.note_readonly).toBe(true);
+ expect(row_state.reclf_exists).toBe(false);
+ expect(row_state.delete_exists).toBe(false);
+ });
+});