From 486e6017a3deb6eef485f697274fbe63fd228d34 Mon Sep 17 00:00:00 2001 From: Elliott Imhoff Date: Tue, 13 Oct 2026 09:59:38 -0500 Subject: [PATCH 1/3] Add add_overlay_canvas() / remove_overlay_canvas() embedder overlay API Embedders get a pointer-free canvas in the image wrapper that ULabel sizes, zoom-syncs, and removes on destroy(). FilterDistance's overlay joins the same overlay list; its behavior is unchanged. CVML-194 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/tasks.md | 10 ++ CHANGELOG.md | 4 + api_spec.md | 17 +++ index.d.ts | 15 ++- src/blobs.d.ts | 2 + src/blobs.js | 5 + src/index.js | 90 +++++++++++++- tests/e2e/overlay-canvas.spec.js | 207 +++++++++++++++++++++++++++++++ tests/overlay_canvas.test.js | 129 +++++++++++++++++++ 9 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/overlay-canvas.spec.js create mode 100644 tests/overlay_canvas.test.js diff --git a/.github/tasks.md b/.github/tasks.md index 063f5245..d588f955 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,5 +1,15 @@ ## Tasks +## Embedder overlay canvases (CVML-194) + +- [x] Named overlay z-index constants in `src/blobs.js`. +- [x] `add_overlay_canvas(layer?)` / `remove_overlay_canvas(canvas)` on `ULabel`; overlay list + resized by `rezoom()` / `drag_rezoom()`; FilterDistance overlay unchanged. +- [x] `destroy()` removes remaining overlay canvases. +- [x] Types in `index.d.ts`, docs in `api_spec.md`, changelog entry. +- [x] Jest + Playwright tests. +- [x] Lint, build, jest. + ## Swap performance: blank frame + AnnotationList cost - [x] `_swap_subtask_annotations`: keep old canvases on screen through the loader yield; diff --git a/CHANGELOG.md b/CHANGELOG.md index 9835a102..f9e3abb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented here. +## [unreleased] +#### Added +- `add_overlay_canvas(layer?)` returns a pointer-free canvas that an embedder can draw into. ULabel places it in the image wrapper, keeps it in sync with zoom, and removes it on `destroy()`. `layer` is `"below_annotations"` (default: above the image, below every subtask) or `"above_annotations"` (above the active subtask). `remove_overlay_canvas(canvas)` detaches one early. New `OverlayLayer` type. + ## [0.29.1] - Oct 12th, 2026 #### Fixed - `set_annotations()` / `set_annotations_batch()` could paint a frame with no annotations on heavy images. The outgoing annotations now stay on screen until the new ones are drawn. diff --git a/api_spec.md b/api_spec.md index 7a8df0b6..64f1ece6 100644 --- a/api_spec.md +++ b/api_spec.md @@ -798,6 +798,22 @@ Display utilities are provided for a constructed `ULabel` object. The new image must match the dimensions this instance was initialized with: the canvases, zoom math, and loaded annotations are all in the init-time image's coordinate space. On a mismatch the old image is restored and the returned `Promise` rejects. Rebuild the ULabel instance to change image dimensions. +Overlay canvases from [`add_overlay_canvas()`](#add_overlay_canvaslayerbelow_annotations) stay in place across a swap; the caller is responsible for their content. + +### `add_overlay_canvas(layer="below_annotations")` + +*("below_annotations" | "above_annotations") => HTMLCanvasElement* -- Creates a canvas for the embedder to draw into and appends it to the image wrapper. ULabel owns its placement: + +- Its backing size is `image_width * px_per_px` by `image_height * px_per_px`. Draw in that pixel space. +- It has the image-size class (`imgsz_class`), `pointer-events: none`, and `image-rendering: pixelated`. ULabel keeps its CSS size in sync with zoom, so pan and zoom never require a redraw. +- `"below_annotations"` stacks it above the image frames and below every subtask canvas. `"above_annotations"` stacks it above the active subtask canvas. + +Throws if called before `init()` completes or after [`destroy()`](#destroy). Each call creates a new canvas. + +### `remove_overlay_canvas(canvas)` + +*(HTMLCanvasElement) => void* -- Detaches a canvas returned by `add_overlay_canvas()` and drops ULabel's reference to it. Unknown canvases are ignored. `destroy()` removes any overlay canvases still registered. + ### `swap_anno_bg_color(new_bg_color)` *(string) => string* -- Changes the background color for the annotation box. Returns the old color. @@ -860,6 +876,7 @@ Note that ULabel will not function properly after this method is called. Designe - toolbox item back-references to the instance, - resize observers and (if [`auto_destroy_on_detach`](#auto_destroy_on_detach) installed one) the auto-teardown `MutationObserver`, - pending toast/interaction timers, +- overlay canvases from [`add_overlay_canvas()`](#add_overlay_canvaslayerbelow_annotations), - all DOM under the configured container. After calling `destroy()` the instance MUST NOT be used again. `set_annotations()`, `get_annotations()`, and `redraw_all_annotations()` short-circuit with a warning if called on a destroyed instance. diff --git a/index.d.ts b/index.d.ts index 2b5aacac..4680abe8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -56,6 +56,9 @@ export type FilterDistanceOverride = { show_overlay: boolean; }; +/** Where an `add_overlay_canvas()` canvas stacks relative to the image frames and subtask canvases. */ +export type OverlayLayer = "below_annotations" | "above_annotations"; + export type DistanceOverlayInfo = { multi_class_mode: boolean; zoom_val: number; @@ -476,7 +479,9 @@ export class ULabel { color_info: { [key: number]: string }; valid_class_ids: number[]; toolbox_order?: number[]; - filter_distance_overlay?: FilterDistanceOverlay; + filter_distance_overlay?: FilterDistanceOverlay | null; + // Canvases resized on every zoom: FilterDistance's overlay and any from add_overlay_canvas(). + overlay_canvases: HTMLCanvasElement[]; begining_time: number; is_init: boolean; resize_observers: ResizeObserver[]; @@ -512,6 +517,14 @@ export class ULabel { public show_initial_crop(): void; public show_whole_image(): void; public swap_frame_image(new_src: string, frame?: number): Promise; + /** + * Returns a pointer-free canvas that ULabel owns, sizes, zoom-syncs, and removes on destroy(). + * Throws before init completes or after destroy(). + */ + public add_overlay_canvas(layer?: OverlayLayer): HTMLCanvasElement; + /** Detach a canvas from `add_overlay_canvas()` and drop ULabel's reference to it. */ + public remove_overlay_canvas(canvas: HTMLCanvasElement): void; + public resize_overlay_canvases(new_width: number, new_height: number): void; public swap_anno_bg_color(new_bg_color: string): string; /** * Set a class's color and sync every view of it: `color_info`, the diff --git a/src/blobs.d.ts b/src/blobs.d.ts index 9b3aee7a..a9fecd39 100644 --- a/src/blobs.d.ts +++ b/src/blobs.d.ts @@ -16,3 +16,5 @@ export const COLORS: string[]; export const BUTTON_LOADER_HTML: string; export const FRONT_Z_INDEX: number; export const BACK_Z_INDEX: number; +export const OVERLAY_BELOW_ANNOTATIONS_Z_INDEX: number; +export const OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX: number; diff --git a/src/blobs.js b/src/blobs.js index d081c836..add18007 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -1,6 +1,10 @@ // z-indices for canvases const FRONT_Z_INDEX = 100; const BACK_Z_INDEX = 75; +// Embedder overlay canvases from `add_overlay_canvas()`. Image frames sit below BACK_Z_INDEX and +// the subtask canvas groups use BACK_Z_INDEX / FRONT_Z_INDEX. +const OVERLAY_BELOW_ANNOTATIONS_Z_INDEX = BACK_Z_INDEX - 1; +const OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX = FRONT_Z_INDEX + 1; const DEMO_ANNOTATION = { id: "7c64913a-9d8c-475a-af1a-658944e37c31", new: true, created_by: "TestUser", created_at: "2020-12-21T02:41:47.304Z", deprecated: false, spatial_type: "contour", spatial_payload: [[4, 25], [4, 25], [4, 24], [4, 23], [4, 22], [4, 22], [5, 22], [5, 21], [5, 20], [6, 20], [6, 19], [7, 19], [7, 18], [8, 18], [8, 18], [10, 18], [11, 18], [11, 17], [12, 17], [12, 16], [12, 16], [13, 16], [14, 15], [16, 14], [16, 14], [17, 14], [18, 14], [18, 13], [19, 13], [20, 13], [20, 13], [21, 13], [22, 13], [23, 13], [24, 13], [24, 13], [25, 13], [26, 13], [27, 13], [28, 13], [28, 13], [29, 13], [30, 13], [31, 13], [32, 13], [34, 13], [36, 14], [36, 14], [37, 15], [40, 15], [40, 16], [41, 16], [42, 17], [43, 17], [44, 18], [44, 18], [45, 18], [46, 18], [47, 18], [47, 18], [48, 18], [48, 18], [49, 19], [50, 20], [52, 20], [52, 20], [53, 21], [54, 21], [55, 21], [56, 21], [57, 21], [58, 22], [59, 22], [60, 22], [60, 22], [61, 22], [63, 22], [64, 22], [64, 22], [65, 22], [66, 22], [67, 22], [68, 22], [68, 21], [69, 21], [70, 20], [70, 19], [71, 19], [71, 18], [72, 18], [72, 18], [72, 18], [73, 18], [75, 17], [75, 16], [76, 16], [76, 16], [76, 15], [77, 14], [78, 14], [79, 14], [79, 13], [79, 12], [80, 12], [81, 12], [82, 11], [83, 11], [84, 10], [85, 10], [86, 10], [87, 10], [88, 10], [88, 10], [89, 10], [90, 10], [91, 10], [92, 10], [92, 10], [93, 10], [94, 10], [94, 10], [95, 10], [96, 10], [96, 11], [96, 11], [98, 11], [98, 12], [99, 12], [100, 13], [100, 14], [101, 14], [101, 15], [102, 15], [104, 16], [104, 17], [104, 18], [105, 18], [106, 18], [106, 18], [107, 18], [107, 19], [107, 20], [108, 20], [108, 21], [108, 21], [108, 22], [109, 22], [109, 22], [109, 23]], classification_payloads: null, annotation_meta: "is_assigned_to_each_annotation" }; const BBOX_SVG = ` { + window.ulabel.add_overlay_canvas().id = "test-overlay-below"; + window.ulabel.add_overlay_canvas("above_annotations").id = "test-overlay-above"; + }); +} + +/** + * Returns the rendered CSS size of an element and of the first image frame. + * @param {import('@playwright/test').Page} page + * @param {string} selector + */ +async function sizes(page, selector) { + return await page.evaluate((sel) => { + const el = document.querySelector(sel); + const frame = document.getElementById(`${window.ulabel.config.image_id_pfx}__0`); + const r = el.getBoundingClientRect(); + const f = frame.getBoundingClientRect(); + return { el: [r.left, r.top, r.width, r.height], frame: [f.left, f.top, f.width, f.height] }; + }, selector); +} + +/** + * Asserts the overlay covers the image frame exactly. + * @param {{el: number[], frame: number[]}} s + */ +function expect_aligned(s) { + for (let i = 0; i < 4; i++) { + expect(Math.abs(s.el[i] - s.frame[i])).toBeLessThanOrEqual(0.5); + } +} + +test.describe("Overlay canvases", () => { + test("creates pointer-free canvases in the image wrapper at image resolution", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + const info = await page.evaluate(() => { + const ul = window.ulabel; + const imwrap = document.getElementById(ul.config.imwrap_id); + return ["test-overlay-below", "test-overlay-above"].map((id) => { + const canvas = document.getElementById(id); + const cs = getComputedStyle(canvas); + return { + in_imwrap: canvas.parentElement === imwrap, + width: canvas.width, + height: canvas.height, + expected_width: ul.config.image_width * ul.config.px_per_px, + expected_height: ul.config.image_height * ul.config.px_per_px, + size_class: canvas.classList.contains(ul.config.imgsz_class), + pointer_events: cs.pointerEvents, + }; + }); + }); + + for (const c of info) { + expect(c.in_imwrap).toBe(true); + expect(c.width).toBe(c.expected_width); + expect(c.height).toBe(c.expected_height); + expect(c.size_class).toBe(true); + expect(c.pointer_events).toBe("none"); + } + expect_aligned(await sizes(page, "#test-overlay-below")); + }); + + test("stacks relative to the image frames and subtask canvases", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + const z = await page.evaluate(() => { + const ul = window.ulabel; + const z_of = (el) => Number(getComputedStyle(el).zIndex); + const groups = [...document.querySelectorAll("div.canvasses")]; + const active = document.getElementById(`canvasses__${ul.get_current_subtask_key()}`); + return { + frame: z_of(document.getElementById(`${ul.config.image_id_pfx}__0`)), + lowest_subtask: Math.min(...groups.map(z_of)), + active_subtask: z_of(active), + n_subtasks: groups.length, + below: z_of(document.getElementById("test-overlay-below")), + above: z_of(document.getElementById("test-overlay-above")), + }; + }); + + expect(z.n_subtasks).toBeGreaterThan(1); + expect(z.below).toBeGreaterThan(z.frame); + expect(z.below).toBeLessThan(z.lowest_subtask); + expect(z.above).toBeGreaterThan(z.active_subtask); + }); + + test("stays aligned with the image through rezoom and drag-zoom", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + await page.evaluate(() => { + window.ulabel.set_zoom_val(window.ulabel.state.zoom_val * 2.5); + window.ulabel.rezoom(); + }); + expect_aligned(await sizes(page, "#test-overlay-below")); + expect_aligned(await sizes(page, "#test-overlay-above")); + + // Shift+drag upward zooms through drag_rezoom() + const box = await page.locator("#test-overlay-below").boundingBox(); + const start = { x: 400, y: Math.min(500, box.y + box.height - 10) }; + const zoom_before = await page.evaluate(() => window.ulabel.state.zoom_val); + await page.keyboard.down("Shift"); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x, start.y - 150, { steps: 10 }); + await page.mouse.up(); + await page.keyboard.up("Shift"); + expect(await page.evaluate(() => window.ulabel.state.zoom_val)).toBeGreaterThan(zoom_before); + + expect_aligned(await sizes(page, "#test-overlay-below")); + expect_aligned(await sizes(page, "#test-overlay-above")); + }); + + test("keeps the FilterDistance overlay in sync alongside embedder overlays", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + await page.evaluate(() => { + window.ulabel.set_zoom_val(window.ulabel.state.zoom_val * 1.7); + window.ulabel.rezoom(); + }); + + const filter = await page.evaluate(() => { + const canvas = document.getElementById("ulabel-filter-distance-overlay"); + const frame = document.getElementById(`${window.ulabel.config.image_id_pfx}__0`); + return { + z: canvas.style.zIndex, + width: canvas.style.width, + frame_width: frame.style.width, + height: canvas.style.height, + frame_height: frame.style.height, + }; + }); + expect(filter.z).toBe("95"); + expect(filter.width).toBe(filter.frame_width); + expect(filter.height).toBe(filter.frame_height); + }); + + test("frame swaps leave overlay canvases and their pixels in place", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + const result = await page.evaluate(async () => { + const ul = window.ulabel; + const canvas = document.getElementById("test-overlay-below"); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "rgb(255, 0, 0)"; + ctx.fillRect(0, 0, 4, 4); + const frame = document.getElementById(`${ul.config.image_id_pfx}__0`); + await ul.swap_frame_image(frame.src); + return { + connected: canvas.isConnected, + registered: ul.overlay_canvases.includes(canvas), + red: ctx.getImageData(1, 1, 1, 1).data[0], + }; + }); + + expect(result).toEqual({ connected: true, registered: true, red: 255 }); + }); + + test("remove_overlay_canvas and destroy clean up", async ({ page }) => { + await wait_for_ulabel_init(page); + await add_overlays(page); + + const result = await page.evaluate(() => { + const ul = window.ulabel; + const below = document.getElementById("test-overlay-below"); + const above = document.getElementById("test-overlay-above"); + ul.remove_overlay_canvas(below); + const after_remove = { + below_connected: below.isConnected, + registered: ul.overlay_canvases.includes(below), + above_connected: above.isConnected, + }; + ul.destroy(); + let threw = false; + try { + ul.add_overlay_canvas(); + } catch { + threw = true; + } + return { + after_remove, + above_connected_after_destroy: above.isConnected, + registered_after_destroy: ul.overlay_canvases.length, + threw, + }; + }); + + expect(result.after_remove).toEqual({ below_connected: false, registered: false, above_connected: true }); + expect(result.above_connected_after_destroy).toBe(false); + expect(result.registered_after_destroy).toBe(0); + expect(result.threw).toBe(true); + }); +}); diff --git a/tests/overlay_canvas.test.js b/tests/overlay_canvas.test.js new file mode 100644 index 00000000..fda407ea --- /dev/null +++ b/tests/overlay_canvas.test.js @@ -0,0 +1,129 @@ +// Tests for add_overlay_canvas() / remove_overlay_canvas() +const { ULabel } = require("./testing-utils/build_loader"); + +describe("overlay canvases", () => { + const container_id = "test-container"; + const mock_config = { + container_id, + image_data: "test-image.png", + username: "test-user", + px_per_px: 2, + submit_buttons: [{ name: "Submit", hook: jest.fn() }], + subtasks: { + test_task: { + display_name: "Test Task", + classes: [{ name: "TestClass", id: 1, color: "red" }], + allowed_modes: ["bbox"], + resume_from: null, + }, + }, + }; + + // Stand in for an init-ed instance: the image wrapper, init-time dimensions, and zoom. + function scaffold_instance() { + const ulabel = new ULabel(mock_config); + document.body.innerHTML = ` +
+
+
+
+
+ `; + ulabel._owned_container = document.getElementById(container_id); + ulabel.config.image_width = 100; + ulabel.config.image_height = 50; + ulabel.state.zoom_val = 1.5; + ulabel.is_init = true; + return ulabel; + } + + function imwrap(ulabel) { + return document.getElementById(ulabel.config.imwrap_id); + } + + test("creates one pointer-free canvas in the image wrapper", () => { + const ulabel = scaffold_instance(); + + const canvas = ulabel.add_overlay_canvas(); + + expect(canvas.parentElement).toBe(imwrap(ulabel)); + expect(imwrap(ulabel).querySelectorAll("canvas")).toHaveLength(1); + expect(canvas.width).toBe(200); + expect(canvas.height).toBe(100); + expect(canvas.classList.contains(ulabel.config.imgsz_class)).toBe(true); + expect(canvas.style.pointerEvents).toBe("none"); + expect(canvas.style.imageRendering).toBe("pixelated"); + expect(canvas.style.position).toBe("absolute"); + expect(canvas.style.width).toBe("150px"); + expect(canvas.style.height).toBe("75px"); + expect(ulabel.overlay_canvases).toEqual([canvas]); + }); + + test("stacks below_annotations by default and above_annotations on request", () => { + const ulabel = scaffold_instance(); + + const below = Number(ulabel.add_overlay_canvas().style.zIndex); + const above = Number(ulabel.add_overlay_canvas("above_annotations").style.zIndex); + + // Image frames are 50; subtask canvas groups are 75 (back) and 100 (front). + expect(below).toBeGreaterThan(50); + expect(below).toBeLessThan(75); + expect(above).toBeGreaterThan(100); + }); + + test("rejects an unknown layer", () => { + const ulabel = scaffold_instance(); + + expect(() => ulabel.add_overlay_canvas("sideways")).toThrow(/Unknown overlay layer/); + expect(ulabel.overlay_canvases).toEqual([]); + }); + + test("throws before init and after destroy", () => { + const ulabel = scaffold_instance(); + ulabel.is_init = false; + expect(() => ulabel.add_overlay_canvas()).toThrow(/before init/); + + ulabel.is_init = true; + ulabel.destroy(); + expect(() => ulabel.add_overlay_canvas()).toThrow(/after destroy/); + }); + + test("resize_overlay_canvases sets the CSS size of every overlay", () => { + const ulabel = scaffold_instance(); + const first = ulabel.add_overlay_canvas(); + const second = ulabel.add_overlay_canvas("above_annotations"); + + ulabel.resize_overlay_canvases(300, 150); + + for (const canvas of [first, second]) { + expect(canvas.style.width).toBe("300px"); + expect(canvas.style.height).toBe("150px"); + expect(canvas.width).toBe(200); + } + }); + + test("remove_overlay_canvas detaches the canvas and drops the reference", () => { + const ulabel = scaffold_instance(); + const first = ulabel.add_overlay_canvas(); + const second = ulabel.add_overlay_canvas(); + + ulabel.remove_overlay_canvas(first); + + expect(first.isConnected).toBe(false); + expect(ulabel.overlay_canvases).toEqual([second]); + // Unknown or already-removed canvases are ignored. + ulabel.remove_overlay_canvas(first); + ulabel.remove_overlay_canvas(document.createElement("canvas")); + expect(ulabel.overlay_canvases).toEqual([second]); + }); + + test("destroy removes remaining overlay canvases", () => { + const ulabel = scaffold_instance(); + const canvas = ulabel.add_overlay_canvas(); + + ulabel.destroy(); + + expect(canvas.isConnected).toBe(false); + expect(ulabel.overlay_canvases).toEqual([]); + }); +}); From 36f2d3b27239e4796df06fa171910e3d33fa5871 Mon Sep 17 00:00:00 2001 From: Elliott Imhoff Date: Tue, 13 Oct 2026 10:44:03 -0500 Subject: [PATCH 2/3] Prepare ulabel 0.30.0 release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/tasks.md | 7 +++++++ CHANGELOG.md | 2 +- index.d.ts | 3 ++- package-lock.json | 4 ++-- package.json | 2 +- src/version.js | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index d588f955..c4e39382 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -18,3 +18,10 @@ inline Inkscape SVG per row. - [x] AnnotationList: `content-visibility: auto` on rows; id->index map in the grouped view. - [x] Lint, build, jest 515/515. + +## PR #263 review fixes + +- [x] Run baseline lint, build, and unit tests. +- [x] Bump package/type versions and release changelog heading to 0.30.0. +- [x] Mark internal canvas members in TypeScript docs and verify the public API spec does not advertise them. +- [x] Run post-change lint, build, and unit tests; commit locally. diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e3abb9..a7a885d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented here. -## [unreleased] +## [0.30.0] - Oct 13th, 2026 #### Added - `add_overlay_canvas(layer?)` returns a pointer-free canvas that an embedder can draw into. ULabel places it in the image wrapper, keeps it in sync with zoom, and removes it on `destroy()`. `layer` is `"below_annotations"` (default: above the image, below every subtask) or `"above_annotations"` (above the active subtask). `remove_overlay_canvas(canvas)` detaches one early. New `OverlayLayer` type. diff --git a/index.d.ts b/index.d.ts index 4680abe8..2f4397f2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -480,7 +480,7 @@ export class ULabel { valid_class_ids: number[]; toolbox_order?: number[]; filter_distance_overlay?: FilterDistanceOverlay | null; - // Canvases resized on every zoom: FilterDistance's overlay and any from add_overlay_canvas(). + /** @internal */ overlay_canvases: HTMLCanvasElement[]; begining_time: number; is_init: boolean; @@ -524,6 +524,7 @@ export class ULabel { public add_overlay_canvas(layer?: OverlayLayer): HTMLCanvasElement; /** Detach a canvas from `add_overlay_canvas()` and drop ULabel's reference to it. */ public remove_overlay_canvas(canvas: HTMLCanvasElement): void; + /** @internal */ public resize_overlay_canvases(new_width: number, new_height: number): void; public swap_anno_bg_color(new_bg_color: string): string; /** diff --git a/package-lock.json b/package-lock.json index ffddee06..d85d8aff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.29.1", + "version": "0.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.29.1", + "version": "0.30.0", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index f00a9169..1c5b46d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.29.1", + "version": "0.30.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/version.js b/src/version.js index 7b8493b2..e1e7a4da 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.29.1"; +export const ULABEL_VERSION = "0.30.0"; From f3602db7c60d9f356b719be873bad4d4f47eee08 Mon Sep 17 00:00:00 2001 From: Elliott Imhoff Date: Wed, 21 Oct 2026 15:41:08 -0500 Subject: [PATCH 3/3] Address PR #263 review: name z-indices, drop redundant overlay resize - Add IMAGE_FRAME_Z_INDEX, FILTER_DISTANCE_OVERLAY_Z_INDEX and LOADER_Z_INDEX to blobs.js; the loader now stacks above "above_annotations" overlays. - Remove resize_overlay_canvases(): overlay canvases carry imgsz_class, so rezoom()/drag_rezoom() already resize them. Restore the FilterDistance overlay resize call; overlay_canvases tracks embedder canvases only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/tasks.md | 6 ++++++ index.d.ts | 2 -- src/blobs.d.ts | 3 +++ src/blobs.js | 15 ++++++++++----- src/html_builder.ts | 3 ++- src/index.js | 24 +++++------------------- src/loader.ts | 4 +++- src/overlays.ts | 3 ++- tests/overlay_canvas.test.js | 14 -------------- 9 files changed, 31 insertions(+), 43 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 9fbd6fbc..779f3665 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -46,3 +46,9 @@ - [x] Merge `origin/main` (0.30.0 and 0.30.1 already released there); resolve version conflicts. - [x] Re-bump package/version.js to 0.31.0; move the overlay API changelog entry under 0.31.0. - [x] Run lint, build, and unit tests. + +## PR #263 review: z-indices and overlay resize + +- [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. diff --git a/index.d.ts b/index.d.ts index fdb578a1..a3f7aa8c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -524,8 +524,6 @@ export class ULabel { public add_overlay_canvas(layer?: OverlayLayer): HTMLCanvasElement; /** Detach a canvas from `add_overlay_canvas()` and drop ULabel's reference to it. */ public remove_overlay_canvas(canvas: HTMLCanvasElement): void; - /** @internal */ - public resize_overlay_canvases(new_width: number, new_height: number): void; public swap_anno_bg_color(new_bg_color: string): string; /** * Set a class's color and sync every view of it: `color_info`, the diff --git a/src/blobs.d.ts b/src/blobs.d.ts index a9fecd39..a26bdbfd 100644 --- a/src/blobs.d.ts +++ b/src/blobs.d.ts @@ -18,3 +18,6 @@ export const FRONT_Z_INDEX: number; export const BACK_Z_INDEX: number; export const OVERLAY_BELOW_ANNOTATIONS_Z_INDEX: number; export const OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX: number; +export const IMAGE_FRAME_Z_INDEX: number; +export const FILTER_DISTANCE_OVERLAY_Z_INDEX: number; +export const LOADER_Z_INDEX: number; diff --git a/src/blobs.js b/src/blobs.js index add18007..c8753c0e 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -1,10 +1,14 @@ -// z-indices for canvases -const FRONT_Z_INDEX = 100; +// z-indices for the image wrapper stack, bottom to top +const IMAGE_FRAME_Z_INDEX = 50; const BACK_Z_INDEX = 75; -// Embedder overlay canvases from `add_overlay_canvas()`. Image frames sit below BACK_Z_INDEX and -// the subtask canvas groups use BACK_Z_INDEX / FRONT_Z_INDEX. +const FRONT_Z_INDEX = 100; +// Embedder overlay canvases from `add_overlay_canvas()` const OVERLAY_BELOW_ANNOTATIONS_Z_INDEX = BACK_Z_INDEX - 1; const OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX = FRONT_Z_INDEX + 1; +// FilterDistance overlay: above inactive subtasks, below the active one +const FILTER_DISTANCE_OVERLAY_Z_INDEX = FRONT_Z_INDEX - 5; +// Loading overlay covers everything above, including "above_annotations" overlays +const LOADER_Z_INDEX = OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX + 1; const DEMO_ANNOTATION = { id: "7c64913a-9d8c-475a-af1a-658944e37c31", new: true, created_by: "TestUser", created_at: "2020-12-21T02:41:47.304Z", deprecated: false, spatial_type: "contour", spatial_payload: [[4, 25], [4, 25], [4, 24], [4, 23], [4, 22], [4, 22], [5, 22], [5, 21], [5, 20], [6, 20], [6, 19], [7, 19], [7, 18], [8, 18], [8, 18], [10, 18], [11, 18], [11, 17], [12, 17], [12, 16], [12, 16], [13, 16], [14, 15], [16, 14], [16, 14], [17, 14], [18, 14], [18, 13], [19, 13], [20, 13], [20, 13], [21, 13], [22, 13], [23, 13], [24, 13], [24, 13], [25, 13], [26, 13], [27, 13], [28, 13], [28, 13], [29, 13], [30, 13], [31, 13], [32, 13], [34, 13], [36, 14], [36, 14], [37, 15], [40, 15], [40, 16], [41, 16], [42, 17], [43, 17], [44, 18], [44, 18], [45, 18], [46, 18], [47, 18], [47, 18], [48, 18], [48, 18], [49, 19], [50, 20], [52, 20], [52, 20], [53, 21], [54, 21], [55, 21], [56, 21], [57, 21], [58, 22], [59, 22], [60, 22], [60, 22], [61, 22], [63, 22], [64, 22], [64, 22], [65, 22], [66, 22], [67, 22], [68, 22], [68, 21], [69, 21], [70, 20], [70, 19], [71, 19], [71, 18], [72, 18], [72, 18], [72, 18], [73, 18], [75, 17], [75, 16], [76, 16], [76, 16], [76, 15], [77, 14], [78, 14], [79, 14], [79, 13], [79, 12], [80, 12], [81, 12], [82, 11], [83, 11], [84, 10], [85, 10], [86, 10], [87, 10], [88, 10], [88, 10], [89, 10], [90, 10], [91, 10], [92, 10], [92, 10], [93, 10], [94, 10], [94, 10], [95, 10], [96, 10], [96, 11], [96, 11], [98, 11], [98, 12], [99, 12], [100, 13], [100, 14], [101, 14], [101, 15], [102, 15], [104, 16], [104, 17], [104, 18], [105, 18], [106, 18], [106, 18], [107, 18], [107, 19], [107, 20], [108, 20], [108, 21], [108, 21], [108, 22], [109, 22], [109, 22], [109, 23]], classification_payloads: null, annotation_meta: "is_assigned_to_each_annotation" }; const BBOX_SVG = ` + `; } return images_html; diff --git a/src/index.js b/src/index.js index 7d762f0e..4f669f44 100644 --- a/src/index.js +++ b/src/index.js @@ -846,7 +846,7 @@ export class ULabel { this.annotation_navigation_toast_timeout = null; // Set true by destroy(); subsequent calls short-circuit. this.is_destroyed = false; - // Overlay canvases resized on every zoom: FilterDistance's and any from add_overlay_canvas(). + // Embedder canvases from add_overlay_canvas(), removed on destroy(). this.overlay_canvases = []; // MutationObserver used by opt-in auto-teardown; may be null. this.mutation_observer = null; @@ -962,7 +962,6 @@ export class ULabel { ); $("#" + this.config["imwrap_id"]).prepend(this.filter_distance_overlay.get_canvas()); - this.overlay_canvases.push(this.filter_distance_overlay.get_canvas()); // Filter the points with an override filter_points_distance_from_line(this, true, null, { @@ -1012,7 +1011,7 @@ export class ULabel { canvas.style.zIndex = String(z_index); canvas.style.pointerEvents = "none"; canvas.style.imageRendering = "pixelated"; - // Match the current zoom; rezoom() keeps it in sync afterwards. + // Match the current zoom; rezoom() resizes every imgsz_class element afterwards. const zoom_val = this.state["zoom_val"]; canvas.style.width = Math.round(this.config["image_width"] * zoom_val) + "px"; canvas.style.height = Math.round(this.config["image_height"] * zoom_val) + "px"; @@ -1035,19 +1034,6 @@ export class ULabel { canvas.remove(); } - /** - * Set the CSS size of every overlay canvas. Backing sizes stay fixed at image resolution. - * - * @param {number} new_width CSS width in px - * @param {number} new_height CSS height in px - */ - resize_overlay_canvases(new_width, new_height) { - for (const canvas of this.overlay_canvases) { - canvas.style.width = new_width + "px"; - canvas.style.height = new_height + "px"; - } - } - handle_toolbox_overflow() { try { let tabs_height = $("#" + this.config["container_id"] + " div.toolbox-tabs").height(); @@ -8860,7 +8846,7 @@ export class ULabel { var toresize = $("." + this.config["imgsz_class"]); toresize.css("width", new_width + "px"); toresize.css("height", new_height + "px"); - this.resize_overlay_canvases(new_width, new_height); + this.filter_distance_overlay?.resize_canvas(new_width, new_height); this.resize_active_polygon_ender(); // Scroll from drag-start baseline @@ -8931,8 +8917,8 @@ export class ULabel { toresize.css("width", new_width + "px"); toresize.css("height", new_height + "px"); - // Apply new size to overlay canvases - this.resize_overlay_canvases(new_width, new_height); + // Apply new size to overlay if overlay exists + this.filter_distance_overlay?.resize_canvas(new_width, new_height); // Apply new size to an active polygon ender this.resize_active_polygon_ender(); diff --git a/src/loader.ts b/src/loader.ts index 4d5e07bf..c1391070 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,3 +1,5 @@ +import { LOADER_Z_INDEX } from "../src/blobs"; + /** * Animated loader for initial loading screen. */ @@ -85,7 +87,7 @@ export class ULabelLoader { height: 100%; inset: 0; background-color: rgba(0, 0, 0, 0.5); - z-index: 100; + z-index: ${LOADER_Z_INDEX}; } .ulabel-loader { border: 16px solid #f3f3f3; diff --git a/src/overlays.ts b/src/overlays.ts index 4c73ff0a..ebad4765 100644 --- a/src/overlays.ts +++ b/src/overlays.ts @@ -3,6 +3,7 @@ import { ULabelAnnotation } from "./annotation"; import { get_annotation_class_id } from "./annotation_operators"; import { log_message, LogLevel } from "./error_logging"; import { ULabelSpatialPayload2D } from "./geometric_utils"; +import { FILTER_DISTANCE_OVERLAY_Z_INDEX } from "../src/blobs"; /** * Basic class to hold generic methods useful for creating overlays. @@ -55,7 +56,7 @@ class ULabelOverlay { this.canvas.setAttribute("class", "ulabel-overlay"); // Set the overlay to be behind the active subtask canvas, but in front of everything else - this.canvas.style.zIndex = "95"; + this.canvas.style.zIndex = String(FILTER_DISTANCE_OVERLAY_Z_INDEX); this.canvas.style.position = "relative"; // Set the width and height diff --git a/tests/overlay_canvas.test.js b/tests/overlay_canvas.test.js index fda407ea..b27b5607 100644 --- a/tests/overlay_canvas.test.js +++ b/tests/overlay_canvas.test.js @@ -88,20 +88,6 @@ describe("overlay canvases", () => { expect(() => ulabel.add_overlay_canvas()).toThrow(/after destroy/); }); - test("resize_overlay_canvases sets the CSS size of every overlay", () => { - const ulabel = scaffold_instance(); - const first = ulabel.add_overlay_canvas(); - const second = ulabel.add_overlay_canvas("above_annotations"); - - ulabel.resize_overlay_canvases(300, 150); - - for (const canvas of [first, second]) { - expect(canvas.style.width).toBe("300px"); - expect(canvas.style.height).toBe("150px"); - expect(canvas.width).toBe(200); - } - }); - test("remove_overlay_canvas detaches the canvas and drops the reference", () => { const ulabel = scaffold_instance(); const first = ulabel.add_overlay_canvas();