Skip to content

Commit 1dcbafd

Browse files
Merge pull request #245 from SenteraLLC/fix/anno-list-class-order
fix/annotation-list-class-order
2 parents 50515ba + cf70f64 commit 1dcbafd

5 files changed

Lines changed: 115 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented here.
44

55
## [unreleased]
66

7+
## [0.23.6] - May 11th, 2026
8+
- 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
9+
- Add local storage of checkbox options for the `AnnotationList` toolbox item
10+
711
## [0.23.5] - May 6th, 2026
812
- Fix bug where on some browsers, middle-click-drag when annotations were vanished would trigger auto-scroll rather than the normal pan behavior.
913

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ulabel",
33
"description": "An image annotation tool.",
4-
"version": "0.23.5",
4+
"version": "0.23.6",
55
"main": "dist/ulabel.min.js",
66
"module": "dist/ulabel.min.js",
77
"types": "index.d.ts",

src/toolbox_items/annotation_list.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,14 @@ export class AnnotationListToolboxItem extends ToolboxItem {
272272
// Show/hide deprecated annotations checkbox
273273
$(document).on("change.ulabel", "#annotation-list-show-deprecated", (e) => {
274274
this.show_deprecated = (e.target as HTMLInputElement).checked;
275+
set_local_storage_item("ulabel_annotation_list_show_deprecated", this.show_deprecated ? "true" : "false");
275276
this.update_list();
276277
});
277278

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

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

412415
for (let i = 0; i < annotations.length; i++) {
413416
const annotation = annotations[i];
414417
const class_id = this.get_annotation_class_id(annotation);
415-
const class_def = subtask.class_defs.find((def) => def.id === class_id);
418+
const class_def = class_def_by_id.get(class_id);
416419
const class_name = class_def ? class_def.name : "Unknown";
417420
const color = this.ulabel.color_info[class_id] || "#cccccc";
418421
const svg = this.get_spatial_type_svg(annotation.spatial_type!, color);
@@ -437,6 +440,8 @@ export class AnnotationListToolboxItem extends ToolboxItem {
437440
* Build HTML for grouped (by class) list
438441
*/
439442
private build_grouped_list_html(annotations: ULabelAnnotation[], subtask: ULabelSubtask): string {
443+
const class_def_by_id = this.build_class_def_by_id(subtask);
444+
440445
// Group annotations by class
441446
const groups: { [class_id: number]: ULabelAnnotation[] } = {};
442447

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

456+
// Build the render order: first walk class_defs in declared order so the
457+
// group headers appear in the same order as the AnnotationIDToolboxItem.
458+
// Then append any orphan class_ids (not in class_defs) as a defensive
459+
// fallback so we never silently drop annotations.
460+
const ordered_class_ids: number[] = [];
461+
const seen_class_ids: Set<number> = new Set();
462+
for (const def of subtask.class_defs) {
463+
if (groups[def.id]) {
464+
ordered_class_ids.push(def.id);
465+
seen_class_ids.add(def.id);
466+
}
467+
}
468+
const orphan_class_ids = Object.keys(groups)
469+
.map((id_str) => parseInt(id_str))
470+
.filter((id) => !seen_class_ids.has(id))
471+
.sort((a, b) => a - b);
472+
for (const id of orphan_class_ids) {
473+
ordered_class_ids.push(id);
474+
}
475+
451476
// Build HTML for each group
452477
let html = "";
453478

454-
for (const class_id_str in groups) {
455-
const class_id = parseInt(class_id_str);
479+
for (const class_id of ordered_class_ids) {
456480
const group_annotations = groups[class_id];
457-
const class_def = subtask.class_defs.find((def) => def.id === class_id);
481+
const class_def = class_def_by_id.get(class_id);
458482
const class_name = class_def ? class_def.name : "Unknown";
459483
const color = this.ulabel.color_info[class_id] || "#cccccc";
460484

@@ -563,6 +587,20 @@ export class AnnotationListToolboxItem extends ToolboxItem {
563587
return class_id;
564588
}
565589

590+
/**
591+
* Build a Map from class id to ClassDefinition for the given subtask. The
592+
* returned map is intended to be used for the duration of a single render so
593+
* that per-annotation class lookups don't repeat a linear search through
594+
* `subtask.class_defs`.
595+
*/
596+
private build_class_def_by_id(subtask: ULabelSubtask): Map<number, ULabelSubtask["class_defs"][number]> {
597+
const map = new Map<number, ULabelSubtask["class_defs"][number]>();
598+
for (const def of subtask.class_defs) {
599+
map.set(def.id, def);
600+
}
601+
return map;
602+
}
603+
566604
/**
567605
* Get the HTML for this toolbox item
568606
*/
@@ -604,23 +642,43 @@ export class AnnotationListToolboxItem extends ToolboxItem {
604642
* Code called after all of ULabel's constructor and initialization code is called
605643
*/
606644
public after_init(): void {
607-
// Restore collapsed state from localStorage
608-
this.restore_collapsed_state();
645+
// Restore persisted UI state from localStorage
646+
this.restore_persisted_state();
609647

610648
// Initial list update
611649
this.update_list();
612650
}
613651

614652
/**
615-
* Restore the collapsed state from localStorage
653+
* Restore persisted UI state (collapsed flag + checkbox toggles) from localStorage.
616654
*/
617-
private restore_collapsed_state(): void {
618-
const stored_state = get_local_storage_item("ulabel_annotation_list_collapsed");
619-
if (stored_state === "false") {
655+
private restore_persisted_state(): void {
656+
const stored_collapsed = get_local_storage_item("ulabel_annotation_list_collapsed");
657+
if (stored_collapsed === "false") {
620658
this.is_collapsed = false;
621-
} else if (stored_state === "true") {
659+
} else if (stored_collapsed === "true") {
622660
this.is_collapsed = true;
623661
}
662+
663+
const stored_show_deprecated = get_local_storage_item("ulabel_annotation_list_show_deprecated");
664+
if (stored_show_deprecated === "true") {
665+
this.show_deprecated = true;
666+
} else if (stored_show_deprecated === "false") {
667+
this.show_deprecated = false;
668+
}
669+
670+
const stored_group_by_class = get_local_storage_item("ulabel_annotation_list_group_by_class");
671+
if (stored_group_by_class === "true") {
672+
this.group_by_class = true;
673+
} else if (stored_group_by_class === "false") {
674+
this.group_by_class = false;
675+
}
676+
677+
// Reflect restored values into the checkbox DOM so the UI matches state.
678+
const show_deprecated_input = document.querySelector<HTMLInputElement>("#annotation-list-show-deprecated");
679+
if (show_deprecated_input) show_deprecated_input.checked = this.show_deprecated;
680+
const group_by_class_input = document.querySelector<HTMLInputElement>("#annotation-list-group-by-class");
681+
if (group_by_class_input) group_by_class_input.checked = this.group_by_class;
624682
}
625683

626684
/**

src/version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const ULABEL_VERSION = "0.23.5";
1+
export const ULABEL_VERSION = "0.23.6";

tests/e2e/annotation_list.spec.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,44 @@ test.describe("Annotation List Grouping", () => {
121121
expect(await header.count()).toBeGreaterThan(0);
122122
}
123123
});
124+
125+
test("class group headers appear in class_defs order, not numeric class id order", async ({ page }) => {
126+
await wait_for_ulabel_init(page);
127+
128+
// multi-class.html defines class_defs as [Sedan(10), SUV(11), Truck(12)].
129+
// Use Sedan + SUV here because those are the two classes with default
130+
// keybinds ("1" and "2") in the demo. Truck (id 12) has no default
131+
// keybind, so pressing "3" would be a no-op and silently leave the
132+
// active class unchanged.
133+
await draw_bbox(page, [100, 100], [200, 200]);
134+
await page.mouse.move(300, 300);
135+
await page.keyboard.press("2");
136+
await draw_bbox(page, [300, 300], [400, 400]);
137+
138+
// Enable group-by-class and confirm the natural ordering matches
139+
// class_defs (Sedan before SUV).
140+
await toggle_group_by_class(page, true);
141+
let header_texts = await page.locator(".annotation-list-class-group-header").allTextContents();
142+
// headers contain the class name plus a count like "(1)"; strip whitespace
143+
let names_in_order = header_texts.map((t) => t.trim().split(/\s+/)[0]);
144+
expect(names_in_order).toEqual(["Sedan", "SUV"]);
145+
146+
// Now reverse class_defs at runtime. With the old (buggy) implementation
147+
// the headers would still appear in ascending numeric class_id order
148+
// ([Sedan(10), SUV(11)]). With the fix they should follow the new
149+
// class_defs order ([SUV, Sedan]).
150+
await page.evaluate(() => {
151+
const subtask = window.ulabel.subtasks[window.ulabel.state.current_subtask];
152+
subtask.class_defs.reverse();
153+
});
154+
155+
// Force the list to re-render. Toggling the checkbox off + on triggers
156+
// build_grouped_list_html.
157+
await toggle_group_by_class(page, false);
158+
await toggle_group_by_class(page, true);
159+
160+
header_texts = await page.locator(".annotation-list-class-group-header").allTextContents();
161+
names_in_order = header_texts.map((t) => t.trim().split(/\s+/)[0]);
162+
expect(names_in_order).toEqual(["SUV", "Sedan"]);
163+
});
124164
});

0 commit comments

Comments
 (0)