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
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.23.6] - May 11th, 2026
- Fix `AnnotationList` class ordering to match the `AnnotationID` toolbox item ordering, which is based on the configured class definition order in the subtask's `classes` array
- Add local storage of checkbox options for the `AnnotationList` toolbox item

## [0.23.5] - May 6th, 2026
- Fix bug where on some browsers, middle-click-drag when annotations were vanished would trigger auto-scroll rather than the normal pan behavior.

Expand Down
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.23.5",
"version": "0.23.6",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "index.d.ts",
Expand Down
80 changes: 69 additions & 11 deletions src/toolbox_items/annotation_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,14 @@ export class AnnotationListToolboxItem extends ToolboxItem {
// Show/hide deprecated annotations checkbox
$(document).on("change.ulabel", "#annotation-list-show-deprecated", (e) => {
this.show_deprecated = (e.target as HTMLInputElement).checked;
set_local_storage_item("ulabel_annotation_list_show_deprecated", this.show_deprecated ? "true" : "false");
this.update_list();
Comment thread
TrevorBurgoyne marked this conversation as resolved.
});

// Group by class checkbox
$(document).on("change.ulabel", "#annotation-list-group-by-class", (e) => {
this.group_by_class = (e.target as HTMLInputElement).checked;
set_local_storage_item("ulabel_annotation_list_group_by_class", this.group_by_class ? "true" : "false");
this.update_list();
});

Expand Down Expand Up @@ -407,12 +409,13 @@ export class AnnotationListToolboxItem extends ToolboxItem {
* Build HTML for flat (non-grouped) list
*/
private build_flat_list_html(annotations: ULabelAnnotation[], subtask: ULabelSubtask): string {
const class_def_by_id = this.build_class_def_by_id(subtask);
let html = "";

for (let i = 0; i < annotations.length; i++) {
const annotation = annotations[i];
const class_id = this.get_annotation_class_id(annotation);
const class_def = subtask.class_defs.find((def) => def.id === class_id);
const class_def = class_def_by_id.get(class_id);
const class_name = class_def ? class_def.name : "Unknown";
const color = this.ulabel.color_info[class_id] || "#cccccc";
const svg = this.get_spatial_type_svg(annotation.spatial_type!, color);
Expand All @@ -437,6 +440,8 @@ export class AnnotationListToolboxItem extends ToolboxItem {
* Build HTML for grouped (by class) list
*/
private build_grouped_list_html(annotations: ULabelAnnotation[], subtask: ULabelSubtask): string {
const class_def_by_id = this.build_class_def_by_id(subtask);

// Group annotations by class
const groups: { [class_id: number]: ULabelAnnotation[] } = {};

Expand All @@ -448,13 +453,32 @@ export class AnnotationListToolboxItem extends ToolboxItem {
groups[class_id].push(annotation);
}

// Build the render order: first walk class_defs in declared order so the
// group headers appear in the same order as the AnnotationIDToolboxItem.
// Then append any orphan class_ids (not in class_defs) as a defensive
// fallback so we never silently drop annotations.
const ordered_class_ids: number[] = [];
const seen_class_ids: Set<number> = new Set();
for (const def of subtask.class_defs) {
if (groups[def.id]) {
ordered_class_ids.push(def.id);
seen_class_ids.add(def.id);
}
}
const orphan_class_ids = Object.keys(groups)
.map((id_str) => parseInt(id_str))
.filter((id) => !seen_class_ids.has(id))
.sort((a, b) => a - b);
for (const id of orphan_class_ids) {
ordered_class_ids.push(id);
}

// Build HTML for each group
let html = "";

for (const class_id_str in groups) {
const class_id = parseInt(class_id_str);
for (const class_id of ordered_class_ids) {
const group_annotations = groups[class_id];
const class_def = subtask.class_defs.find((def) => def.id === class_id);
const class_def = class_def_by_id.get(class_id);
const class_name = class_def ? class_def.name : "Unknown";
Comment thread
TrevorBurgoyne marked this conversation as resolved.
const color = this.ulabel.color_info[class_id] || "#cccccc";

Expand Down Expand Up @@ -563,6 +587,20 @@ export class AnnotationListToolboxItem extends ToolboxItem {
return class_id;
}

/**
* Build a Map from class id to ClassDefinition for the given subtask. The
* returned map is intended to be used for the duration of a single render so
* that per-annotation class lookups don't repeat a linear search through
* `subtask.class_defs`.
*/
private build_class_def_by_id(subtask: ULabelSubtask): Map<number, ULabelSubtask["class_defs"][number]> {
const map = new Map<number, ULabelSubtask["class_defs"][number]>();
for (const def of subtask.class_defs) {
map.set(def.id, def);
}
return map;
}

/**
* Get the HTML for this toolbox item
*/
Expand Down Expand Up @@ -604,23 +642,43 @@ export class AnnotationListToolboxItem extends ToolboxItem {
* Code called after all of ULabel's constructor and initialization code is called
*/
public after_init(): void {
// Restore collapsed state from localStorage
this.restore_collapsed_state();
// Restore persisted UI state from localStorage
this.restore_persisted_state();

// Initial list update
this.update_list();
}

/**
* Restore the collapsed state from localStorage
* Restore persisted UI state (collapsed flag + checkbox toggles) from localStorage.
*/
private restore_collapsed_state(): void {
const stored_state = get_local_storage_item("ulabel_annotation_list_collapsed");
if (stored_state === "false") {
private restore_persisted_state(): void {
const stored_collapsed = get_local_storage_item("ulabel_annotation_list_collapsed");
if (stored_collapsed === "false") {
this.is_collapsed = false;
} else if (stored_state === "true") {
} else if (stored_collapsed === "true") {
this.is_collapsed = true;
}

const stored_show_deprecated = get_local_storage_item("ulabel_annotation_list_show_deprecated");
if (stored_show_deprecated === "true") {
this.show_deprecated = true;
} else if (stored_show_deprecated === "false") {
this.show_deprecated = false;
}

const stored_group_by_class = get_local_storage_item("ulabel_annotation_list_group_by_class");
if (stored_group_by_class === "true") {
this.group_by_class = true;
} else if (stored_group_by_class === "false") {
this.group_by_class = false;
}

// Reflect restored values into the checkbox DOM so the UI matches state.
const show_deprecated_input = document.querySelector<HTMLInputElement>("#annotation-list-show-deprecated");
if (show_deprecated_input) show_deprecated_input.checked = this.show_deprecated;
const group_by_class_input = document.querySelector<HTMLInputElement>("#annotation-list-group-by-class");
if (group_by_class_input) group_by_class_input.checked = this.group_by_class;
}

/**
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.23.5";
export const ULABEL_VERSION = "0.23.6";
40 changes: 40 additions & 0 deletions tests/e2e/annotation_list.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,44 @@ test.describe("Annotation List Grouping", () => {
expect(await header.count()).toBeGreaterThan(0);
}
});

test("class group headers appear in class_defs order, not numeric class id order", async ({ page }) => {
await wait_for_ulabel_init(page);

// multi-class.html defines class_defs as [Sedan(10), SUV(11), Truck(12)].
// Use Sedan + SUV here because those are the two classes with default
// keybinds ("1" and "2") in the demo. Truck (id 12) has no default
// keybind, so pressing "3" would be a no-op and silently leave the
// active class unchanged.
await draw_bbox(page, [100, 100], [200, 200]);
await page.mouse.move(300, 300);
await page.keyboard.press("2");
await draw_bbox(page, [300, 300], [400, 400]);

// Enable group-by-class and confirm the natural ordering matches
// class_defs (Sedan before SUV).
await toggle_group_by_class(page, true);
let header_texts = await page.locator(".annotation-list-class-group-header").allTextContents();
// headers contain the class name plus a count like "(1)"; strip whitespace
let names_in_order = header_texts.map((t) => t.trim().split(/\s+/)[0]);
expect(names_in_order).toEqual(["Sedan", "SUV"]);

// Now reverse class_defs at runtime. With the old (buggy) implementation
// the headers would still appear in ascending numeric class_id order
// ([Sedan(10), SUV(11)]). With the fix they should follow the new
// class_defs order ([SUV, Sedan]).
await page.evaluate(() => {
const subtask = window.ulabel.subtasks[window.ulabel.state.current_subtask];
subtask.class_defs.reverse();
});

// Force the list to re-render. Toggling the checkbox off + on triggers
// build_grouped_list_html.
await toggle_group_by_class(page, false);
await toggle_group_by_class(page, true);

header_texts = await page.locator(".annotation-list-class-group-header").allTextContents();
names_in_order = header_texts.map((t) => t.trim().split(/\s+/)[0]);
expect(names_in_order).toEqual(["SUV", "Sedan"]);
});
});
Loading