From d66b1ad6cabec39b9fb14219f165e88ebf3c398d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Fri, 23 Oct 2026 18:50:17 -0500 Subject: [PATCH 1/6] add change class context button --- .github/tasks.md | 7 +++ CHANGELOG.md | 4 ++ api_spec.md | 14 ++++- index.d.ts | 18 +++++- package-lock.json | 4 +- package.json | 2 +- src/actions.ts | 11 +++- src/context_menu.ts | 17 +++++- src/index.js | 121 +++++++++++++++++++++++++++++++++++++ src/version.js | 2 +- tests/context_menu.test.js | 33 ++++++++++ tests/counterparts.test.js | 96 +++++++++++++++++++++++++++++ 12 files changed, 316 insertions(+), 13 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 779f3665..0742d30d 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -52,3 +52,10 @@ - [x] Name the image frame (50), FilterDistance overlay (95), and loader z-indices in `src/blobs.js`; loader sits above `"above_annotations"` overlays. - [x] Drop `resize_overlay_canvases()`: overlays carry `imgsz_class`, so rezoom already resizes them. Restore the FilterDistance resize call; `overlay_canvases` holds embedder canvases only. - [x] Lint, build, jest 521/521 (both builds), Chromium e2e 94/94. + +## Set class in (context menu) + +- [x] `set_counterparts_class()` in `src/index.js` + undo/redo (`set_counterparts_class` action). +- [x] Context menu item `Set class in` (only counterparts that can take the source class and differ). +- [x] Types (`index.d.ts`), `api_spec.md`, changelog. +- [x] Jest tests; lint, build, jest. diff --git a/CHANGELOG.md b/CHANGELOG.md index b86ba2ed..d0fb9974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented here. +## [Unreleased] +#### Added +- **Set class in ``** context-menu item: sets an annotation's counterparts in another writable subtask to its class (matched by id, then name). Counterparts that can't take that class, or already have it, are left alone, and the item only shows when at least one would change. New `get_counterpart_class_changes()` and `set_counterparts_class()`, recorded as one undoable `set_counterparts_class` action on the target subtask. + ## [0.31.1] - Oct 22nd, 2026 #### Changed - Simplified ternary syntax into nullish coalescing in several locations diff --git a/api_spec.md b/api_spec.md index e3a8f0fe..59a5c997 100644 --- a/api_spec.md +++ b/api_spec.md @@ -17,7 +17,7 @@ In a read-only subtask, undo and redo act on the only writable subtask, if there - Press `Escape` to exit brush/erase mode. - Press `Tab` to set the zoom to focus on the next annotation - Press `Shift+Tab` to set the zoom to focus on the previous annotation -- Right-click a hovered annotation (or its entry in the `AnnotationList` toolbox item) to open a context menu with `Change class`, `Copy to`, `Move to`, `Delete`, `Isolate` / `Show all`, and `Details`. `Change class` opens the class pie for that annotation; `Copy to` / `Move to` clone the annotation into another subtask at the same image coordinates (see [`copy_annotation`](#copy_annotationannotation_id-target_subtask_key-class_id-source_subtask_key)) -- a single eligible subtask is named on the item, several open a list to pick from, and `Move to` also deletes the source (one undoable action on the source subtask that also removes the copy and the target's history about it). Both also arm `ctrl+v` with the annotation, and when the target already has a counterpart of it (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) a confirm dialog asks before copying again. `Delete from` appears for every other writable subtask holding counterparts and deletes them there, reading `Delete from ` (or ` ()` in the list) when there is more than one; a bitmask also offers `Erase from`, which only subtracts its mask (see [`delete_counterparts`](#delete_counterpartsannotation_id-source_key-target_key-erase)). The view stays on the source subtask unless the class pie opens or [`paste_switch_to_target`](#paste_switch_to_target) is on; `Isolate` hides every other annotation in the subtask (see [`isolate_annotation`](#isolate_annotationannotation_id-subtask_key-redraw)) and reads `Show all` while that annotation is isolated; `Details` lists its id, class, spatial type, last editor/edit time, what deprecated it (deprecated annotations only), and `annotation_meta` entries. Read-only subtasks only offer `Copy to`, `Delete from` / `Erase from`, `Isolate`, and `Details`. A deprecated annotation (hoverable only under deprecated focus, see **Show Deprecated** in the `AnnotationList` toolbox item) offers `Restore`, `Isolate` / `Show all`, and `Details`; `Restore` appears only for annotations deleted by a human and nothing else, on writable subtasks, and not for a bitmask erased to nothing (see [`restore_annotation`](#restore_annotationannotation_id)). Press `Escape` or click anywhere to close it. A right-click while drawing a polyline still finishes the polyline and opens no menu. +- Right-click a hovered annotation (or its entry in the `AnnotationList` toolbox item) to open a context menu with `Change class`, `Copy to`, `Move to`, `Delete`, `Isolate` / `Show all`, and `Details`. `Change class` opens the class pie for that annotation; `Copy to` / `Move to` clone the annotation into another subtask at the same image coordinates (see [`copy_annotation`](#copy_annotationannotation_id-target_subtask_key-class_id-source_subtask_key)) -- a single eligible subtask is named on the item, several open a list to pick from, and `Move to` also deletes the source (one undoable action on the source subtask that also removes the copy and the target's history about it). Both also arm `ctrl+v` with the annotation, and when the target already has a counterpart of it (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) a confirm dialog asks before copying again. `Delete from` appears for every other writable subtask holding counterparts and deletes them there, reading `Delete from ` (or ` ()` in the list) when there is more than one; a bitmask also offers `Erase from`, which only subtracts its mask (see [`delete_counterparts`](#delete_counterpartsannotation_id-source_key-target_key-erase)). `Set class in` appears for every other writable subtask holding counterparts that can take the annotation's class (matched by id, else by name) and don't already have it, and sets them to it (see [`set_counterparts_class`](#set_counterparts_classannotation_id-source_key-target_key)). The view stays on the source subtask unless the class pie opens or [`paste_switch_to_target`](#paste_switch_to_target) is on; `Isolate` hides every other annotation in the subtask (see [`isolate_annotation`](#isolate_annotationannotation_id-subtask_key-redraw)) and reads `Show all` while that annotation is isolated; `Details` lists its id, class, spatial type, last editor/edit time, what deprecated it (deprecated annotations only), and `annotation_meta` entries. Read-only subtasks only offer `Copy to`, `Set class in`, `Delete from` / `Erase from`, `Isolate`, and `Details`. A deprecated annotation (hoverable only under deprecated focus, see **Show Deprecated** in the `AnnotationList` toolbox item) offers `Restore`, `Isolate` / `Show all`, and `Details`; `Restore` appears only for annotations deleted by a human and nothing else, on writable subtasks, and not for a bitmask erased to nothing (see [`restore_annotation`](#restore_annotationannotation_id)). Press `Escape` or click anywhere to close it. A right-click while drawing a polyline still finishes the polyline and opens no menu. - Press `Escape` to close an open class pie or context menu. - `ctrl+c` / `cmd+c` copies the hovered annotation, `ctrl+x` / `cmd+x` cuts it (deletes it after copying; not in read-only subtasks), and `ctrl+v` / `cmd+v` pastes into the current subtask (see [`paste_annotation_from_clipboard`](#paste_annotation_from_clipboardenvelope)). The copy is placed on the system clipboard as JSON, so it can be pasted into another ULabel instance showing an image of the same dimensions; a paste back into the subtask it was copied from keeps its class; the first paste into another subtask keeps the coordinates, and every repeat paste into the same subtask (or any paste back into the source) is offset by a further 20 pixels. These shortcuts are not remappable and are ignored while a text field has focus. @@ -677,7 +677,7 @@ When `true` (the default), a plain left-drag that starts inside a spatial annota When `true` (the default), a context-menu `Copy to` / `Move to` or a `ctrl+v` paste whose source class has no counterpart in the target subtask (by id, then by name) opens the class pie on the pasted annotation when several classes could take it. Set to `false` to never open the pie: the copy silently takes the target's active class (else its first compatible class). Useful when the source subtask's classes (e.g. `True Positive` / `False Negative`) can never match the target's and the host keeps the target's active class in sync with its own class picker. ### `paste_switch_to_target` -When `true`, a context-menu `Copy to` / `Move to` / `Delete from` / `Erase from` makes the target the current subtask afterwards (firing [`on_subtask_change`](#on_subtask_change)), whether or not the class pie opens; an undo/redo from a read-only subtask likewise switches to the writable subtask it acts on. Default is `false`: the view only switches when the class pie opens on the copy. Toggle at runtime with [`set_paste_switch_to_target`](#set_paste_switch_to_targetenabled). `ctrl+v` pastes into the current subtask and is unaffected. +When `true`, a context-menu `Copy to` / `Move to` / `Set class in` / `Delete from` / `Erase from` makes the target the current subtask afterwards (firing [`on_subtask_change`](#on_subtask_change)), whether or not the class pie opens; an undo/redo from a read-only subtask likewise switches to the writable subtask it acts on. Default is `false`: the view only switches when the class pie opens on the copy. Toggle at runtime with [`set_paste_switch_to_target`](#set_paste_switch_to_targetenabled). `ctrl+v` pastes into the current subtask and is unaffected. ### `annotation_link_meta_key` An `annotation_meta` key whose value, a string or an array of strings, links annotations across subtasks: two annotations are counterparts when they share any value (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)). Every copy and paste drops this entry from the copy's `annotation_meta`, so a copy never claims its source's identity. Default is `null` (only `copied_from` links). @@ -922,12 +922,20 @@ Sets the zoom to focus on the provided annotation, and switches to its subtask i ### `find_counterparts(annotation_id, source_key, target_key)` -*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Ids of the spatial annotations in `target_key` linked to `annotation_id` in `source_key`: copies of it, the annotation it was copied from (both via `annotation_meta.copied_from`), and annotations sharing a value under [`annotation_link_meta_key`](#annotation_link_meta_key). Human-deleted annotations are skipped; filter-hidden ones count. Empty when `source_key === target_key`. The context menu uses this for the copy confirm and for `Delete from` / `Erase from`. +*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Ids of the spatial annotations in `target_key` linked to `annotation_id` in `source_key`: copies of it, the annotation it was copied from (both via `annotation_meta.copied_from`), and annotations sharing a value under [`annotation_link_meta_key`](#annotation_link_meta_key). Human-deleted annotations are skipped; filter-hidden ones count. Empty when `source_key === target_key`. The context menu uses this for the copy confirm and for `Set class in` / `Delete from` / `Erase from`. ### `delete_counterparts(annotation_id, source_key, target_key, erase?)` *(annotation_id: string, source_key: string, target_key: string, erase?: boolean | null) => string[]* -- Removes the counterparts (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) from `target_key`, which must not be read-only; the source may be. With `erase` (default: whether the source is a bitmask; only a bitmask source can erase), the source mask is subtracted from its bitmask counterparts (a mask erased to nothing is deleted) and other counterparts are left alone; otherwise every counterpart is deleted. Recorded as one `delete_counterparts` action on the target subtask's stream (undo there restores every mask and deletion) and reported to [`on_annotation_change`](#on_annotation_change) with the target as `subtask_key` and the changed counterparts as `affected`. Returns the changed ids; nothing is recorded when none changed. When something changed and [`paste_switch_to_target`](#paste_switch_to_target) is on, the target becomes the current subtask. +### `get_counterpart_class_changes(annotation_id, source_key, target_key)` + +*(annotation_id: string, source_key: string, target_key: string) => Record* -- The counterparts (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) in `target_key` that can take the source annotation's class and don't already have it, each mapped to the class id it would take. The class is matched by id, else by name (the first compatible class with that name); counterparts with no compatible match are left out. The context menu shows `Set class in` for a subtask only when this is non-empty. + +### `set_counterparts_class(annotation_id, source_key, target_key)` + +*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Sets each counterpart from [`get_counterpart_class_changes`](#get_counterpart_class_changesannotation_id-source_key-target_key) to its matched class at full confidence; the rest are left alone. `target_key` must not be read-only; the source may be. Recorded as one `set_counterparts_class` action on the target subtask's stream (undo there restores every previous classification) and reported to [`on_annotation_change`](#on_annotation_change) with the target as `subtask_key` and the changed counterparts as `affected`. Returns the changed ids; nothing is recorded when none changed. When something changed and [`paste_switch_to_target`](#paste_switch_to_target) is on, the target becomes the current subtask. + ### `set_paste_switch_to_target(enabled)` / `get_paste_switch_to_target()` *(enabled: boolean) => void* / *() => boolean* -- Read or change [`paste_switch_to_target`](#paste_switch_to_target) at runtime. diff --git a/index.d.ts b/index.d.ts index a3f7aa8c..0a7095d4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -269,6 +269,7 @@ export type ULabelActionType = "create_nonspatial_annotation" | "paste_annotation" | "move_annotation" | "delete_counterparts" | + "set_counterparts_class" | "begin_annotation" | "continue_annotation" | "finish_annotation" | @@ -402,7 +403,7 @@ export type ULabelConstructorArgs = { allow_body_move?: boolean; /** Open the class pie on a paste whose source class has no id/name match in the target. Default true; false takes the target's active class. */ paste_class_choice?: boolean; - /** Make the target the current subtask after a copy/move or `delete_counterparts`, and the writable subtask on undo/redo from a read-only one. Default false. */ + /** Make the target the current subtask after a copy/move, `delete_counterparts` or `set_counterparts_class`, and the writable subtask on undo/redo from a read-only one. Default false. */ paste_switch_to_target?: boolean; /** `annotation_meta` key whose value (string or string[]) links annotations across subtasks; any shared value links. Copies drop it. Default null. */ annotation_link_meta_key?: string | null; @@ -820,7 +821,18 @@ export class ULabel { * With `config.paste_switch_to_target`, switches to the target when anything changed. */ public delete_counterparts(annotation_id: string, source_key: string, target_key: string, erase?: boolean | null): string[]; - /** Whether a context-menu copy/move/delete-from makes the target the current subtask. */ + /** + * Counterparts in `target_key` that can take the source's class (by id, else by name) + * and don't already have it, mapped to the class id each would take. + */ + public get_counterpart_class_changes(annotation_id: string, source_key: string, target_key: string): Record; + /** + * Set the counterparts in a writable `target_key` to the source's class (see `get_counterpart_class_changes`). + * One `set_counterparts_class` action on the target's stream. Returns the changed ids. + * With `config.paste_switch_to_target`, switches to the target when anything changed. + */ + public set_counterparts_class(annotation_id: string, source_key: string, target_key: string): string[]; + /** Whether a context-menu copy/move/delete-from/set-class-in makes the target the current subtask. */ public set_paste_switch_to_target(enabled: boolean): void; public get_paste_switch_to_target(): boolean; public start_complex_polygon(annotation_id?: string): void; @@ -860,6 +872,7 @@ export class ULabel { public paste_annotation__undo(annotation_id: string): void; public move_annotation__undo(annotation_id: string, undo_payload: object): void; public delete_counterparts__undo(undo_payload: object): void; + public set_counterparts_class__undo(undo_payload: object): void; public create_nonspatial_annotation__undo(annotation_id: string): void; public start_complex_polygon__undo(annotation_id: string): void; public merge_polygon_complex_layer__undo(annotation_id: string, undo_payload: object): void; @@ -884,6 +897,7 @@ export class ULabel { public paste_annotation__redo(annotation_id: string, redo_payload: object): void; public move_annotation__redo(annotation_id: string, redo_payload: object): void; public delete_counterparts__redo(annotation_id: string, redo_payload: object): void; + public set_counterparts_class__redo(annotation_id: string, redo_payload: object): void; public finish_modify_annotation__redo(annotation_id: string, redo_payload: object): void; // Mouse event handlers diff --git a/package-lock.json b/package-lock.json index cbc50981..34894a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.31.0", + "version": "0.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.31.0", + "version": "0.32.0", "license": "MIT", "devDependencies": { "@eslint/config-helpers": "^0.7.0", diff --git a/package.json b/package.json index fb99a90f..e5b487e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.31.1", + "version": "0.32.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 27e17ca1..bc98c563 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -434,6 +434,9 @@ function trigger_action_listeners( delete_counterparts: { // Redraws its target subtask itself }, + set_counterparts_class: { + // Redraws its target subtask itself + }, }; // Call the appropriate listener @@ -727,7 +730,7 @@ export function redo(ulabel: ULabel) { */ function get_action_boxes(ulabel: ULabel, action: ULabelAction): ULabelContainingBox[] { // A counterpart action's own id is its source annotation, in another subtask - const own = action.act_type === "delete_counterparts" ? + const own = action.act_type === "delete_counterparts" || action.act_type === "set_counterparts_class" ? [] : [{ annotation_id: action.annotation_id, subtask_key: ulabel.get_current_subtask_key() }]; const targets = [...own, ...(action.affected ?? [])]; @@ -829,6 +832,9 @@ function undo_action(ulabel: ULabel, action: ULabelAction) { case "delete_counterparts": ulabel.delete_counterparts__undo(undo_payload); break; + case "set_counterparts_class": + ulabel.set_counterparts_class__undo(undo_payload); + break; case "create_nonspatial_annotation": ulabel.create_nonspatial_annotation__undo(annotation_id); break; @@ -925,6 +931,9 @@ export function redo_action(ulabel: ULabel, action: ULabelAction) { case "delete_counterparts": ulabel.delete_counterparts__redo(annotation_id, redo_payload); break; + case "set_counterparts_class": + ulabel.set_counterparts_class__redo(annotation_id, redo_payload); + break; case "create_nonspatial_annotation": ulabel.create_nonspatial_annotation(annotation_id, redo_payload); break; diff --git a/src/context_menu.ts b/src/context_menu.ts index b260f7ac..45c95ba1 100644 --- a/src/context_menu.ts +++ b/src/context_menu.ts @@ -124,7 +124,7 @@ function render_details( } /** - * "Copy to" / "Move to" / "Delete from" entry. One eligible subtask collapses to a flat + * "Copy to" / "Move to" / "Set class in" / "Delete from" entry. One eligible subtask collapses to a flat * "