diff --git a/.github/tasks.md b/.github/tasks.md index 761cfe4f..779f3665 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; @@ -9,6 +19,13 @@ - [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. + ## Fix GitHub Pages demo (blank page) - [x] `live_demo.html`: load `./cs-demo-0.png` (relative) so it resolves under `/ulabel/`. @@ -23,3 +40,15 @@ - [x] Test: move the mouse off the list before counting pixels (Firefox hovers the re-rendered row, drawing a thicker outline). - [x] Verified: Firefox 50/50 with no settle wait; list-related e2e 156/156; lint; jest 515/515. + +## Merge main for PR #263 + +- [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/CHANGELOG.md b/CHANGELOG.md index 658f37b8..080779ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented here. ## [Unreleased] +## [0.31.0] - Oct 21st, 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. + ## [0.30.1] - Oct 21st, 2026 #### Fixed - `AnnotationList` rows that had not been drawn yet reserved 54px instead of 41px, so the list jumped as rows scrolled into view (most visible in Firefox). diff --git a/api_spec.md b/api_spec.md index 06889884..e3a8f0fe 100644 --- a/api_spec.md +++ b/api_spec.md @@ -806,6 +806,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. @@ -868,6 +884,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 34041fd9..a3f7aa8c 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; + /** @internal */ + overlay_canvases: HTMLCanvasElement[]; begining_time: number; is_init: boolean; resize_observers: ResizeObserver[]; @@ -512,6 +517,13 @@ 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 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/package-lock.json b/package-lock.json index 4f319c33..6234e231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.30.1", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.30.1", + "version": "0.31.0", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index 2edd0f2e..a9a47ba6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.30.1", + "version": "0.31.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/blobs.d.ts b/src/blobs.d.ts index 9b3aee7a..a26bdbfd 100644 --- a/src/blobs.d.ts +++ b/src/blobs.d.ts @@ -16,3 +16,8 @@ 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; +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 d081c836..c8753c0e 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -1,6 +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; +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 ea6b8bb2..4f669f44 100644 --- a/src/index.js +++ b/src/index.js @@ -53,6 +53,8 @@ import { GLOBAL_SVG, FRONT_Z_INDEX, BACK_Z_INDEX, + OVERLAY_BELOW_ANNOTATIONS_Z_INDEX, + OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX, } from "./blobs"; import { ULABEL_VERSION } from "./version"; import { ulabel_init } from "../build/initializer"; @@ -249,7 +251,14 @@ export class ULabel { // 6. Drop the resize-observer array so disconnected observers stop retaining state. this.resize_observers = []; - // 7. Wipe the container DOM (canvases, id dialogs, brush circle, enders, overlays). + // 7. Detach overlay canvases (embedder overlays may outlive the container wipe via references). + for (const canvas of this.overlay_canvases ?? []) { + canvas.remove(); + } + this.overlay_canvases = []; + this.filter_distance_overlay = null; + + // 8. Wipe the container DOM (canvases, id dialogs, brush circle, enders, overlays). // Prefer the captured owned-container node so a same-id replacement mounted by an SPA // between detach and this call is left untouched. let container = this._owned_container; @@ -837,6 +846,8 @@ export class ULabel { this.annotation_navigation_toast_timeout = null; // Set true by destroy(); subsequent calls short-circuit. this.is_destroyed = false; + // 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; // The specific container HTMLElement this instance owns. Captured at observer-install @@ -966,6 +977,63 @@ export class ULabel { } } + /** + * Create a pointer-free canvas in the image wrapper for an embedder to draw into. + * ULabel sizes it to the image, keeps its CSS size in sync with zoom, and removes it on + * `destroy()`. The caller owns its pixels, including across `swap_frame_image()`. + * + * @param {"below_annotations" | "above_annotations"} layer Stack above the image frames and + * below every subtask canvas (default), or above the active subtask canvas. + * @returns {HTMLCanvasElement} The overlay canvas + */ + add_overlay_canvas(layer = "below_annotations") { + if (this.is_destroyed) throw new Error("add_overlay_canvas() called after destroy()"); + if (!this.is_init) throw new Error("add_overlay_canvas() called before init completed"); + let z_index; + switch (layer) { + case "below_annotations": + z_index = OVERLAY_BELOW_ANNOTATIONS_Z_INDEX; + break; + case "above_annotations": + z_index = OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX; + break; + default: + throw new Error(`Unknown overlay layer "${layer}"`); + } + + const canvas = document.createElement("canvas"); + canvas.width = this.config["image_width"] * this.config["px_per_px"]; + canvas.height = this.config["image_height"] * this.config["px_per_px"]; + canvas.classList.add(this.config["imgsz_class"], "ulabel-embedder-overlay"); + canvas.style.position = "absolute"; + canvas.style.top = "0"; + canvas.style.left = "0"; + canvas.style.zIndex = String(z_index); + canvas.style.pointerEvents = "none"; + canvas.style.imageRendering = "pixelated"; + // 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"; + + document.getElementById(this.config["imwrap_id"]).appendChild(canvas); + this.overlay_canvases.push(canvas); + return canvas; + } + + /** + * Detach an overlay canvas returned by `add_overlay_canvas()` and drop ULabel's reference. + * Unknown canvases are ignored. + * + * @param {HTMLCanvasElement} canvas The overlay canvas to remove + */ + remove_overlay_canvas(canvas) { + const idx = this.overlay_canvases.indexOf(canvas); + if (idx === -1) return; + this.overlay_canvases.splice(idx, 1); + canvas.remove(); + } + handle_toolbox_overflow() { try { let tabs_height = $("#" + this.config["container_id"] + " div.toolbox-tabs").height(); 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/src/version.js b/src/version.js index 53812b65..8754155b 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.30.1"; +export const ULABEL_VERSION = "0.31.0"; diff --git a/tests/e2e/overlay-canvas.spec.js b/tests/e2e/overlay-canvas.spec.js new file mode 100644 index 00000000..3271c9db --- /dev/null +++ b/tests/e2e/overlay-canvas.spec.js @@ -0,0 +1,207 @@ +// End-to-end tests for embedder overlay canvases (add_overlay_canvas / remove_overlay_canvas) +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; + +/** + * Adds one overlay canvas per layer and tags them with ids for lookup. + * @param {import('@playwright/test').Page} page + */ +async function add_overlays(page) { + await page.evaluate(() => { + 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..b27b5607 --- /dev/null +++ b/tests/overlay_canvas.test.js @@ -0,0 +1,115 @@ +// 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("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([]); + }); +});