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 @@ + + + + ULabel - Read-Only Demo + + + + + + + + + + + + +
+

Read-Only Demo

+

+ All subtasks are read_only: true. Hover shows the confidence card and hover outline, but + move/reclassify/delete buttons, vertex handles, and the id color-wheel are suppressed. Creation drags, + brush/erase mode, delete keybinds, and class-reassign keybinds are all blocked. +

+
+
+
+
+ + diff --git a/index.d.ts b/index.d.ts index ec370d04..a22ca5c2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -378,6 +378,7 @@ export class ULabel { // Subtasks public get_current_subtask_key(): string; public get_current_subtask(): ULabelSubtask; + public is_current_subtask_read_only(): boolean; public readjust_subtask_opacities(): void; public set_subtask(st_key: string): void; public switch_to_next_subtask(): void; diff --git a/package-lock.json b/package-lock.json index e2f0bc85..e5b03b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.26.3", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.26.3", + "version": "0.27.0", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index a2f73ee7..35773fd2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.26.3", + "version": "0.27.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/actions.ts b/src/actions.ts index 937fcaf4..9583f1d2 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -156,6 +156,7 @@ export function record_finish_move( undo_payload.diffZ = -diffZ; redo_payload.finished = true; redo_payload.move_not_allowed = move_not_allowed; + undo_payload.move_not_allowed = move_not_allowed; action.redo_payload = JSON.stringify(redo_payload); action.undo_payload = JSON.stringify(undo_payload); diff --git a/src/blobs.js b/src/blobs.js index 0c1943c5..be942e96 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -1862,6 +1862,7 @@ div#${prntid} div.dialogs_container { position: absolute; top: 0; left: 0; + z-index: ${BACK_Z_INDEX + 1}; } div.toolbox_inner_cls { diff --git a/src/html_builder.ts b/src/html_builder.ts index ab093574..aeea5d49 100644 --- a/src/html_builder.ts +++ b/src/html_builder.ts @@ -542,24 +542,27 @@ export function build_confidence_dialog(ulabel: ULabel) { } global_edit_suggestion_jq.append(`
-

Annotation Confidence:

-

- N/A +

+

`); // Style the dialog $("#" + global_id).css({ - "background-color": "black", + "background-color": "rgba(0, 0, 0, 0.75)", "color": "white", - "opacity": "0.6", - "height": "3em", - "width": "14.5em", + "height": "auto", + "width": "auto", + "min-width": "8em", + "padding": "0.2em 0.4em", "margin-top": "-9.5em", - "border-radius": "1em", - "font-size": "1.2em", - "margin-left": "-1.4em", + "border-radius": "0.5em", + "font-size": "1.1em", + "position": "relative", + "left": "50%", + "transform": "translateX(-50%)", + "pointer-events": "none", }); } } diff --git a/src/index.js b/src/index.js index e83d4803..74daee59 100644 --- a/src/index.js +++ b/src/index.js @@ -110,9 +110,12 @@ export class ULabel { if (mouse_event.ctrlKey || mouse_event.metaKey) { return "pan"; } - if (mouse_event.shiftKey) { + if (mouse_event.shiftKey && !ul.get_current_subtask()["state"]["starting_complex_polygon"]) { return "zoom"; } + if (ul.is_current_subtask_read_only()) { + return null; + } return "annotation"; } else if ($(mouse_event.target).hasClass("editable")) { return "edit"; @@ -509,8 +512,6 @@ export class ULabel { } static initialize_subtasks(ul) { - let first_non_ro = null; - // Initialize a place on the ulabel object to hold annotation color information ul.color_info = {}; @@ -523,10 +524,6 @@ export class ULabel { let raw_subtask = ul.config.subtasks[subtask_key]; ul.subtasks[subtask_key] = ULabelSubtask.from_json(subtask_key, raw_subtask); - if (first_non_ro === null && !ul.subtasks[subtask_key]["read_only"]) { - first_non_ro = subtask_key; - } - // Process allowed_modes // They are placed in ul.subtasks[subtask_key]["allowed_modes"] ULabel.process_allowed_modes(ul, subtask_key, raw_subtask); @@ -567,6 +564,7 @@ export class ULabel { is_vanished: false, edit_candidate: null, move_candidate: null, + hovered_annid: null, fly_to_idx: null, line_size: ul.config.initial_line_size, @@ -583,12 +581,6 @@ export class ULabel { // They are placed in ul.subtasks[subtask_key]["annotations"] ULabel.process_resume_from(ul, subtask_key, raw_subtask); } - if (first_non_ro === null) { - log_message( - "You must have at least one subtask without 'read_only' set to true.", - LogLevel.ERROR, - ); - } } static expand_image_data(ul, raw_img_dat) { @@ -1043,6 +1035,14 @@ export class ULabel { return this.subtasks[this.get_current_subtask_key()]; } + /** + * Whether the current subtask is marked read-only (no user mutations allowed). + * Programmatic mutations via public API (e.g., set_annotations) are still permitted. + */ + is_current_subtask_read_only() { + return this.get_current_subtask()["read_only"] === true; + } + readjust_subtask_opacities() { for (const st_key in this.subtasks) { let sliderval = $("#tb-st-range--" + st_key).val(); @@ -1053,6 +1053,18 @@ export class ULabel { set_subtask(st_key) { let old_st = this.get_current_subtask_key(); + // Clear stale hover on the outgoing subtask so its white outline doesn't linger + // (its canvases stay visible at reduced opacity in the background). + const old_subtask = this.subtasks[old_st]; + const old_hovered = old_subtask["state"]["hovered_annid"]; + if (old_hovered !== null) { + old_subtask["state"]["hovered_annid"] = null; + const prev_ann = old_subtask["annotations"]["access"][old_hovered]; + if (prev_ann && prev_ann["canvas_id"]) { + this.redraw_all_annotations_in_annotation_context(prev_ann["canvas_id"], old_st); + } + } + // Change object state this.state["current_subtask"] = st_key; @@ -1759,6 +1771,7 @@ export class ULabel { ctx.lineTo((sp[0] + diffX) * px_per_px, (sp[1] + diffY) * px_per_px); ctx.closePath(); ctx.stroke(); + this.draw_hover_outline(annotation_object, ctx, line_size + 4); } draw_point(annotation_object, ctx, offset = null) { @@ -1792,6 +1805,7 @@ export class ULabel { ctx.arc((sp[0] + diffX) * px_per_px, (sp[1] + diffY) * px_per_px, line_size * px_per_px * 3, 0, 2 * Math.PI); ctx.closePath(); ctx.stroke(); + this.draw_hover_outline(annotation_object, ctx, line_size + 2); } draw_bbox3(annotation_object, ctx, offset = null) { @@ -1843,6 +1857,8 @@ export class ULabel { ctx.fill(); ctx.globalAlpha = 1.0; } + + this.draw_hover_outline(annotation_object, ctx, line_size + 4); } draw_polygon(annotation_object, ctx, offset = null) { @@ -1907,6 +1923,27 @@ export class ULabel { } } + // Draw hover outline behind the annotation strokes + if (this.is_annotation_hovered(annotation_object)) { + ctx.globalCompositeOperation = "destination-over"; + ctx.strokeStyle = "white"; + ctx.lineWidth = (line_size + 4) * px_per_px; + for (let i = 0; i < n_iters; i++) { + if (spatial_type === "polygon" && annotation_object["spatial_payload_holes"][i]) continue; + let hover_payload = spatial_type === "polygon" ? spatial_payload[i] : active_spatial_payload; + const pts = hover_payload; + if (pts.length > 0) { + ctx.beginPath(); + ctx.moveTo((pts[0][0] + diffX) * px_per_px, (pts[0][1] + diffY) * px_per_px); + for (let pti = 1; pti < pts.length; pti++) { + ctx.lineTo((pts[pti][0] + diffX) * px_per_px, (pts[pti][1] + diffY) * px_per_px); + } + ctx.stroke(); + } + } + ctx.globalCompositeOperation = "source-over"; + } + if ( spatial_type === "polygon" && !layer_is_closed && @@ -2093,6 +2130,29 @@ export class ULabel { render.box_height * px_per_px, ); ctx.globalAlpha = 1.0; + + if (this.is_annotation_hovered(annotation_object)) { + // Build a white contour by dilating the mask shape and cutting out the interior + const border = 2; + const ow = render.box_width + border * 2; + const oh = render.box_height + border * 2; + const outline = document.createElement("canvas"); + outline.width = ow; + outline.height = oh; + const octx = outline.getContext("2d"); + const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; + for (const [ox, oy] of dirs) { + octx.drawImage(render.canvas, border + ox * border, border + oy * border); + } + octx.globalCompositeOperation = "source-in"; + octx.fillStyle = "white"; + octx.fillRect(0, 0, ow, oh); + octx.globalCompositeOperation = "destination-out"; + octx.drawImage(render.canvas, border, border); + const blit_x = (render.tlx + diffX - border) * px_per_px; + const blit_y = (render.tly + diffY - border) * px_per_px; + ctx.drawImage(outline, blit_x, blit_y, ow * px_per_px, oh * px_per_px); + } } // Build a native-resolution, class-colored stencil for a bitmask's bounding box. @@ -2199,6 +2259,7 @@ export class ULabel { ctx.lineTo((pts[pti][0] + diffX) * px_per_px, (pts[pti][1] + diffY) * px_per_px); } ctx.stroke(); + this.draw_hover_outline(annotation_object, ctx, line_size + 4); } draw_tbar(annotation_object, ctx, offset = null) { @@ -2249,6 +2310,23 @@ export class ULabel { ctx.lineTo((eb[0] + diffX) * px_per_px, (eb[1] + diffY) * px_per_px); ctx.stroke(); ctx.lineCap = "round"; + + if (this.is_annotation_hovered(annotation_object)) { + ctx.globalCompositeOperation = "destination-over"; + ctx.strokeStyle = "white"; + ctx.lineWidth = (line_size + 4) * px_per_px; + // Re-stroke the main line + ctx.beginPath(); + ctx.moveTo((sp[0] + diffX) * px_per_px, (sp[1] + diffY) * px_per_px); + ctx.lineTo((ep[0] + diffX) * px_per_px, (ep[1] + diffY) * px_per_px); + ctx.stroke(); + // Re-stroke the cross + ctx.beginPath(); + ctx.moveTo((sb[0] + diffX) * px_per_px, (sb[1] + diffY) * px_per_px); + ctx.lineTo((eb[0] + diffX) * px_per_px, (eb[1] + diffY) * px_per_px); + ctx.stroke(); + ctx.globalCompositeOperation = "source-over"; + } } draw_nonspatial_annotation(annotation_object, svg_obj, subtask = null) { @@ -2257,19 +2335,27 @@ export class ULabel { } const annotation_id = annotation_object["id"]; const element_id = this.get_nonspatial_annotation_element_id(annotation_id); + const is_read_only = this.subtasks[subtask]["read_only"] === true; + const note_readonly_attr = is_read_only ? " readonly" : ""; + const reclf_button = is_read_only ? + "" : + `
+ +
`; + const delete_button = is_read_only ? + "" : + `
+ × +
`; if ($(`div#${element_id}`).length === 0) { $(`div#fad_st__${subtask} div.fad_annotation_rows`).append(`
- +
- -
- × -
+ -->${reclf_button}${delete_button}
${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); + }); +});