Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/tasks.md
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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/`.
Expand All @@ -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.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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<string>;
/**
* 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/blobs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 13 additions & 3 deletions src/blobs.js
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
TrevorBurgoyne marked this conversation as resolved.
Resolved
// 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 = `
<svg
Expand Down Expand Up @@ -2308,5 +2316,7 @@ export {
DEMO_ANNOTATION,
get_init_style,
COLORS, BUTTON_LOADER_HTML,
FRONT_Z_INDEX, BACK_Z_INDEX,
FRONT_Z_INDEX, BACK_Z_INDEX, IMAGE_FRAME_Z_INDEX,
OVERLAY_BELOW_ANNOTATIONS_Z_INDEX, OVERLAY_ABOVE_ANNOTATIONS_Z_INDEX,
FILTER_DISTANCE_OVERLAY_Z_INDEX, LOADER_Z_INDEX,
};
3 changes: 2 additions & 1 deletion src/html_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
GLOBAL_SVG,
BITMASK_SVG,
get_init_style,
IMAGE_FRAME_Z_INDEX,
} from "../src/blobs";
import { ULabelLoader } from "./loader";
import { log_message, LogLevel } from "./error_logging";
Expand Down Expand Up @@ -79,7 +80,7 @@ function get_images_html(ulabel: ULabel): string {
display = "block";
}
images_html += `
<img id="${ulabel.config["image_id_pfx"]}__${i}" src="${ulabel.config["image_data"]!.frames[i]}" class="imwrap_cls ${ulabel.config["imgsz_class"]} image_frame" style="z-index: 50; display: ${display};" />
<img id="${ulabel.config["image_id_pfx"]}__${i}" src="${ulabel.config["image_data"]!.frames[i]}" class="imwrap_cls ${ulabel.config["imgsz_class"]} image_frame" style="z-index: ${IMAGE_FRAME_Z_INDEX}; display: ${display};" />
`;
}
return images_html;
Expand Down
70 changes: 69 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LOADER_Z_INDEX } from "../src/blobs";

/**
* Animated loader for initial loading screen.
*/
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const ULABEL_VERSION = "0.30.1";
export const ULABEL_VERSION = "0.31.0";
Loading
Loading