From 90155dd2c8de631eca2283d189cdf411aaefd25b Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 11:21:37 -0500 Subject: [PATCH 01/43] first stab at collapsable toolbox --- .github/tasks.md | 45 ++++++++++++--------------------------------- src/initializer.ts | 11 +++++++++++ src/listeners.ts | 25 ++++++++++++++++++++++++- src/toolbox.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 8866fe7c..948b5d57 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,36 +1,15 @@ ## Tasks -- [x] Read the description in [#234](https://github.com/SenteraLLC/ulabel/issues/234) - - [x] Write a clear summary of the requested change - - [x] Break the requested feature down into concrete steps. Add the steps to the tasks list, and then start working on then one by one. +- [x] Add a sideways, clickable arrow to minimize the entire toolbox + - [x] Add collapse button to toolbox HTML + - [x] Add CSS styles for collapsed state + - [x] Add click handler to toggle collapsed state + - [x] Store collapsed state in localStorage + - [x] Test functionality +- [ ] Create a keybinds toolbox item + - [ ] Should show a list of ALL keybinds that have listeners, some configurable and some not + - [ ] On hover, should provide a brief description of each keybind + - [ ] For configurable keybinds (those that can currently be set in the ulabel config), the user should be able to edit them by inputting a new keybind + - [ ] Keybinds that collide with existing ones can be allowed, but should be highlighted red +- [ ] Add support for keybind "chords" (ie, "shift+i") -### Summary -Create an annotation list toolbox item that displays all annotations in a list format, similar to other annotation tools. The list should: -- Display each annotation (by ID or index) -- Allow show/hide of deprecated annotations (default: hide) -- Support grouping by class -- Enable clicking to "fly to" the annotation -- Show annotation labels/IDs (on hover or drawn on canvas) -- Display "current idx / total" when navigating through annotations -- Highlight annotations when hovering in the list -### Implementation Steps -- [x] 1. Research existing toolbox items and understand the toolbox structure - - [x] Read `src/toolbox.ts` to understand how toolbox items work - - [x] Review existing toolbox items in `src/toolbox_items/` - - [x] Understand how annotation data is accessed and structured -- [x] 2. Create the basic annotation list toolbox item - - [x] Create new file `src/toolbox_items/annotation_list.ts` - - [x] Implement basic UI structure (container, list elements) - - [x] Register the toolbox item in the main toolbox -- [x] 3. Implement core list functionality - - [x] Display all annotations with their ID/index - - [x] Add show/hide toggle for deprecated annotations (default: hide) - - [x] Add option to group by class -- [x] 4. Implement click-to-fly functionality - - [x] Integrate with existing "fly to" functionality from PR #230 - - [x] Add click handlers to list items - - [x] Display "current idx / total" indicator -- [x] 5. Implement hover highlighting - - [x] Add hover handlers to list items - - [x] Integrate with existing annotation highlighting system - - [x] Ensure bidirectional highlighting (list hover → canvas, canvas hover → list) diff --git a/src/initializer.ts b/src/initializer.ts index 3d4bbd59..45195799 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -13,6 +13,7 @@ import { create_ulabel_listeners } from "./listeners"; import { ULabelLoader } from "./loader"; import { ULabelSubtask } from "./subtask"; import { ULabelAnnotation } from "./annotation"; +import { get_local_storage_item } from "./utilities"; /** * Make canvases for each subtask @@ -119,6 +120,16 @@ export async function ulabel_init( // Create listers to manipulate and export this object create_ulabel_listeners(ulabel); + // Restore toolbox collapsed state from localStorage + const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed"); + if (is_collapsed === "true") { + const toolbox = $("#" + ulabel.config["toolbox_id"]); + const btn = toolbox.find(".toolbox-collapse-btn"); + toolbox.addClass("collapsed"); + btn.text("▶"); + btn.attr("title", "Expand toolbox"); + } + ulabel.handle_toolbox_overflow(); // Set the canvas elements in the correct stacking order given current subtask diff --git a/src/listeners.ts b/src/listeners.ts index 59362a92..c5714b27 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -9,6 +9,7 @@ import type { ULabel } from ".."; import { NightModeCookie } from "./cookies"; import { DELETE_CLASS_ID, DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; +import { set_local_storage_item } from "./utilities"; const ULABEL_NAMESPACE = ".ulabel"; @@ -537,7 +538,7 @@ export function create_ulabel_listeners( }, ); - // Button to save annotations + // Button to toggle night mode $(document).on( "click" + ULABEL_NAMESPACE, "#" + ulabel.config["toolbox_id"] + " a.night-button", @@ -553,6 +554,28 @@ export function create_ulabel_listeners( }, ); + // Button to collapse/expand toolbox + $(document).on( + "click" + ULABEL_NAMESPACE, + "#" + ulabel.config["toolbox_id"] + " .toolbox-collapse-btn", + (e) => { + const toolbox = $("#" + ulabel.config["toolbox_id"]); + const btn = $(e.currentTarget); + + if (toolbox.hasClass("collapsed")) { + toolbox.removeClass("collapsed"); + btn.text("◀"); + btn.attr("title", "Collapse toolbox"); + set_local_storage_item("ulabel_toolbox_collapsed", "false"); + } else { + toolbox.addClass("collapsed"); + btn.text("▶"); + btn.attr("title", "Expand toolbox"); + set_local_storage_item("ulabel_toolbox_collapsed", "true"); + } + }, + ); + // Keyboard only events $(document).on( "keydown" + ULABEL_NAMESPACE, diff --git a/src/toolbox.ts b/src/toolbox.ts index 457a2149..0d42d83f 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -110,6 +110,41 @@ export class Toolbox { position: absolute; top: 0; right: 0; + transition: transform 300ms ease-in-out; + } + + #toolbox.collapsed { + transform: translateX(calc(100% - 30px)); + } + + .toolbox-collapse-btn { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 30px; + height: 60px; + background-color: rgba(0, 128, 255, 0.7); + border: 1px solid rgba(128, 128, 128, 0.5); + border-right: none; + border-radius: 5px 0 0 5px; + color: white; + font-size: 1.2rem; + cursor: pointer; + z-index: 1000; + transition: background-color 250ms; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + .toolbox-collapse-btn:hover { + background-color: rgba(0, 128, 255, 0.9); + } + + #toolbox.collapsed .toolbox-collapse-btn { + left: -30px; } .ulabel-night #toolbox { @@ -212,6 +247,7 @@ export class Toolbox {
+

ULabel v${ULABEL_VERSION}

From 9b4e5568383a3975ef7fa5ed4017e588f10f99d2 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 12:08:12 -0500 Subject: [PATCH 02/43] touch up and fix issues with collapse --- .github/tasks.md | 3 +++ src/blobs.js | 1 + src/toolbox.ts | 62 +++++++++++++++++++++++++++--------------------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 948b5d57..2662b516 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -5,6 +5,9 @@ - [x] Add click handler to toggle collapsed state - [x] Store collapsed state in localStorage - [x] Test functionality + - [x] Move arrow to top of toolbox (instead of middle) + - [ ] Make annbox expand when toolbox is collapsed + - [ ] Make collapsed button more visible (40px wide x 80px tall) - [ ] Create a keybinds toolbox item - [ ] Should show a list of ALL keybinds that have listeners, some configurable and some not - [ ] On hover, should provide a brief description of each keybind diff --git a/src/blobs.js b/src/blobs.js index d8304f53..cec7289c 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -2009,6 +2009,7 @@ div#${prntid} a.tbid-opt.sel { div#${prntid} div.toolbox-name-header { background-color: rgb(0, 128, 202); margin: 0; + flex: 7; } div#${prntid}.ulabel-night div.toolbox-name-header { background-color: rgb(0, 60, 95); diff --git a/src/toolbox.ts b/src/toolbox.ts index 0d42d83f..4457f9aa 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -114,29 +114,35 @@ export class Toolbox { } #toolbox.collapsed { - transform: translateX(calc(100% - 30px)); + transform: translateX(calc(100% - 40px)); + } + + .annbox_cls { + transition: width 300ms ease-in-out; + } + + #toolbox.collapsed ~ * .annbox_cls, + .full_ulabel_container_:has(#toolbox.collapsed) .annbox_cls { + width: calc(100% - 40px) !important; + } + + .toolbox-header-container { + display: flex; + align-items: flex-start; + } + + .ulabel-night .toolbox-header-container { + background-color: rgb(0, 60, 95); } .toolbox-collapse-btn { - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 30px; - height: 60px; - background-color: rgba(0, 128, 255, 0.7); - border: 1px solid rgba(128, 128, 128, 0.5); - border-right: none; + flex: 1; + flex-shrink: 0; border-radius: 5px 0 0 5px; color: white; font-size: 1.2rem; - cursor: pointer; - z-index: 1000; - transition: background-color 250ms; - display: flex; - align-items: center; - justify-content: center; - padding: 0; + transition: all 300ms ease-in-out; + padding: 5px 10px; } .toolbox-collapse-btn:hover { @@ -144,7 +150,7 @@ export class Toolbox { } #toolbox.collapsed .toolbox-collapse-btn { - left: -30px; + font-size: 1.2rem; } .ulabel-night #toolbox { @@ -247,15 +253,17 @@ export class Toolbox {
- -
-

ULabel v${ULABEL_VERSION}

- -
-
-
-
+
+ +
+

ULabel v${ULABEL_VERSION}

From 0e4e13bb2eafc14dc14a86a97d5a8f72b0118f17 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 12:20:15 -0500 Subject: [PATCH 03/43] start keybinds --- .github/tasks.md | 16 +- demo/multi-class.html | 1 + src/configuration.ts | 4 + src/toolbox_items/keybinds.ts | 455 ++++++++++++++++++++++++++++++++++ 4 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 src/toolbox_items/keybinds.ts diff --git a/.github/tasks.md b/.github/tasks.md index 2662b516..5c671a97 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -6,13 +6,17 @@ - [x] Store collapsed state in localStorage - [x] Test functionality - [x] Move arrow to top of toolbox (instead of middle) - - [ ] Make annbox expand when toolbox is collapsed - - [ ] Make collapsed button more visible (40px wide x 80px tall) + - [x] Make annbox expand when toolbox is collapsed + - [x] Make collapsed button visible - [ ] Create a keybinds toolbox item - - [ ] Should show a list of ALL keybinds that have listeners, some configurable and some not - - [ ] On hover, should provide a brief description of each keybind - - [ ] For configurable keybinds (those that can currently be set in the ulabel config), the user should be able to edit them by inputting a new keybind - - [ ] Keybinds that collide with existing ones can be allowed, but should be highlighted red + - [x] Research existing keybinds in the codebase + - [x] Create basic keybinds toolbox item file + - [x] Register keybinds toolbox item in configuration + - [ ] Display list of all keybinds (the key) labeled with the name + - [x] Add hover tooltips with detailed descriptions (using title attribute) + - [x] Add collision detection and red highlighting + - [ ] Implement editing for configurable keybinds + - [ ] Test functionality - [ ] Add support for keybind "chords" (ie, "shift+i") diff --git a/demo/multi-class.html b/demo/multi-class.html index 571c5bca..ac9d965b 100644 --- a/demo/multi-class.html +++ b/demo/multi-class.html @@ -119,6 +119,7 @@ "initial_line_size": 2, "toolbox_order": [ AllowedToolboxItem.SubmitButtons, + AllowedToolboxItem.Keybinds, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, AllowedToolboxItem.ImageFilters, diff --git a/src/configuration.ts b/src/configuration.ts index e3a5ad71..315a945e 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -22,6 +22,7 @@ import { import { SubmitButtons } from "./toolbox_items/submit_buttons"; import { ImageFiltersToolboxItem } from "./toolbox_items/image_filters"; import { AnnotationListToolboxItem } from "./toolbox_items/annotation_list"; +import { KeybindsToolboxItem } from "./toolbox_items/keybinds"; import { is_object_and_not_array } from "./utilities"; /* eslint-disable @stylistic/no-multi-spaces */ @@ -38,6 +39,7 @@ export enum AllowedToolboxItem { Brush, // 9 ImageFilters, // 10 AnnotationList, // 11 + Keybinds, // 12 } /* eslint-enable @stylistic/no-multi-spaces */ @@ -135,12 +137,14 @@ export class Configuration { [AllowedToolboxItem.Brush, BrushToolboxItem], [AllowedToolboxItem.ImageFilters, ImageFiltersToolboxItem], [AllowedToolboxItem.AnnotationList, AnnotationListToolboxItem], + [AllowedToolboxItem.Keybinds, KeybindsToolboxItem], ]); // Default toolbox order used when the user doesn't specify one public toolbox_order: AllowedToolboxItem[] = [ AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, + AllowedToolboxItem.Keybinds, AllowedToolboxItem.Brush, AllowedToolboxItem.ImageFilters, AllowedToolboxItem.ZoomPan, diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts new file mode 100644 index 00000000..13c39173 --- /dev/null +++ b/src/toolbox_items/keybinds.ts @@ -0,0 +1,455 @@ +import type { ULabel } from "../index"; +import { ToolboxItem } from "../toolbox"; + +interface KeybindInfo { + key: string; + description: string; + configurable: boolean; + config_key?: string; +} + +/** + * Toolbox item for displaying and editing keybinds + */ +export class KeybindsToolboxItem extends ToolboxItem { + private ulabel: ULabel; + private is_collapsed: boolean = true; + + constructor(ulabel: ULabel) { + super(); + this.ulabel = ulabel; + + this.add_styles(); + } + + /** + * Create the css for this ToolboxItem and append it to the page. + */ + protected add_styles() { + const css = ` + #toolbox .keybinds-toolbox-item { + padding: 0.5rem 0; + } + + #toolbox .keybinds-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1.5rem; + cursor: pointer; + } + + #toolbox .keybinds-title { + margin: 0.5rem 0; + font-size: 1rem; + font-weight: 600; + } + + #toolbox .keybinds-toggle-btn { + background: none; + border: none; + color: inherit; + font-size: 1rem; + cursor: pointer; + padding: 0.25rem; + width: 24px; + height: 24px; + } + + #toolbox .keybinds-toggle-btn:hover { + background-color: rgba(0, 128, 255, 0.1); + } + + #toolbox .keybinds-content { + display: none; + padding: 0 1rem; + max-height: 400px; + overflow-y: auto; + } + + #toolbox .keybinds-content.expanded { + display: block; + } + + #toolbox .keybinds-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + } + + #toolbox .keybind-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem; + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.05); + font-size: 0.85rem; + } + + .ulabel-night #toolbox .keybind-item { + background-color: rgba(255, 255, 255, 0.05); + } + + #toolbox .keybind-item:hover { + background-color: rgba(0, 128, 255, 0.1); + } + + #toolbox .keybind-key { + font-family: monospace; + font-weight: bold; + padding: 0.25rem 0.5rem; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 3px; + min-width: 40px; + text-align: center; + } + + .ulabel-night #toolbox .keybind-key { + background-color: rgba(255, 255, 255, 0.1); + } + + #toolbox .keybind-key.collision { + background-color: rgba(255, 0, 0, 0.3); + border: 1px solid red; + } + + #toolbox .keybind-key.editable { + cursor: pointer; + } + + #toolbox .keybind-key.editing { + outline: 2px solid rgba(0, 128, 255, 0.7); + } + + #toolbox .keybind-description { + flex: 1; + margin-left: 0.75rem; + color: #666; + } + + .ulabel-night #toolbox .keybind-description { + color: #aaa; + } + + #toolbox .keybind-category { + font-weight: 600; + font-size: 0.9rem; + margin-top: 1rem; + margin-bottom: 0.5rem; + padding: 0.25rem 0.5rem; + color: rgba(0, 128, 255, 0.9); + } + `; + + const style_id = "keybinds-toolbox-styles"; + const existing_style = document.getElementById(style_id); + if (existing_style) { + existing_style.remove(); + } + + const style = document.createElement("style"); + style.id = style_id; + style.appendChild(document.createTextNode(css)); + document.head.appendChild(style); + } + + /** + * Get all keybinds from the configuration + */ + private get_all_keybinds(): KeybindInfo[] { + const config = this.ulabel.config; + const keybinds: KeybindInfo[] = []; + + // Configurable keybinds + keybinds.push({ + key: config.change_zoom_keybind, + description: "Change zoom mode (hold: drag zoom, release: single-click zoom)", + configurable: true, + config_key: "change_zoom_keybind", + }); + + keybinds.push({ + key: config.create_point_annotation_keybind, + description: "Create point annotation at mouse location (in point mode)", + configurable: true, + config_key: "create_point_annotation_keybind", + }); + + keybinds.push({ + key: config.delete_annotation_keybind, + description: "Delete (deprecate) the active annotation", + configurable: true, + config_key: "delete_annotation_keybind", + }); + + keybinds.push({ + key: config.switch_subtask_keybind, + description: "Switch to the next subtask", + configurable: true, + config_key: "switch_subtask_keybind", + }); + + keybinds.push({ + key: config.toggle_annotation_mode_keybind, + description: "Toggle between annotation modes", + configurable: true, + config_key: "toggle_annotation_mode_keybind", + }); + + keybinds.push({ + key: config.create_bbox_on_initial_crop, + description: "Create bbox annotation on initial crop area", + configurable: true, + config_key: "create_bbox_on_initial_crop", + }); + + keybinds.push({ + key: config.toggle_brush_mode_keybind, + description: "Toggle brush mode for polygon/contour annotation", + configurable: true, + config_key: "toggle_brush_mode_keybind", + }); + + keybinds.push({ + key: config.toggle_erase_mode_keybind, + description: "Toggle erase mode in brush", + configurable: true, + config_key: "toggle_erase_mode_keybind", + }); + + keybinds.push({ + key: config.increase_brush_size_keybind, + description: "Increase brush size", + configurable: true, + config_key: "increase_brush_size_keybind", + }); + + keybinds.push({ + key: config.decrease_brush_size_keybind, + description: "Decrease brush size", + configurable: true, + config_key: "decrease_brush_size_keybind", + }); + + keybinds.push({ + key: config.fly_to_next_annotation_keybind, + description: "Fly to next annotation", + configurable: true, + config_key: "fly_to_next_annotation_keybind", + }); + + if (config.fly_to_previous_annotation_keybind !== null) { + keybinds.push({ + key: config.fly_to_previous_annotation_keybind, + description: "Fly to previous annotation", + configurable: true, + config_key: "fly_to_previous_annotation_keybind", + }); + } + + // Resize keybinds from default_keybinds + if (config.default_keybinds) { + keybinds.push({ + key: config.default_keybinds.annotation_size_small, + description: "Set annotation size to small", + configurable: false, + }); + + keybinds.push({ + key: config.default_keybinds.annotation_size_large, + description: "Set annotation size to large", + configurable: false, + }); + + keybinds.push({ + key: config.default_keybinds.annotation_size_plus, + description: "Increase annotation size", + configurable: false, + }); + + keybinds.push({ + key: config.default_keybinds.annotation_size_minus, + description: "Decrease annotation size", + configurable: false, + }); + + keybinds.push({ + key: config.default_keybinds.annotation_vanish, + description: "Toggle annotation vanish mode", + configurable: false, + }); + } + + // Add class keybinds + const current_subtask = this.ulabel.get_current_subtask(); + if (current_subtask && current_subtask.class_defs) { + for (const class_def of current_subtask.class_defs) { + if (class_def.keybind !== null) { + keybinds.push({ + key: class_def.keybind, + description: `Select class: ${class_def.name}`, + configurable: false, + }); + } + } + } + + // Non-configurable keybinds (hardcoded in listeners) + keybinds.push({ + key: "Shift+Tab", + description: "Fly to previous annotation (if fly_to_previous_annotation_keybind is null)", + configurable: false, + }); + + return keybinds; + } + + /** + * Check if a key has collisions with other keybinds + */ + private has_collision(key: string, all_keybinds: KeybindInfo[]): boolean { + const occurrences = all_keybinds.filter((kb) => kb.key === key).length; + return occurrences > 1; + } + + /** + * Generate HTML for the toolbox item + */ + public get_html(): string { + const all_keybinds = this.get_all_keybinds(); + + let keybinds_html = ""; + + // Group keybinds by category + const configurable = all_keybinds.filter((kb) => kb.configurable); + const class_binds = all_keybinds.filter((kb) => kb.description.startsWith("Select class:")); + const resize_binds = all_keybinds.filter((kb) => + kb.description.includes("annotation size") || kb.description.includes("vanish"), + ); + const other = all_keybinds.filter((kb) => + !kb.configurable && + !kb.description.startsWith("Select class:") && + !kb.description.includes("annotation size") && + !kb.description.includes("vanish"), + ); + + // Configurable keybinds + if (configurable.length > 0) { + keybinds_html += "
Configurable Keybinds
"; + for (const keybind of configurable) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + keybinds_html += ` +
+ ${keybind.key} + ${keybind.description} +
+ `; + } + } + + // Class keybinds + if (class_binds.length > 0) { + keybinds_html += "
Class Selection
"; + for (const keybind of class_binds) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + keybinds_html += ` +
+ ${keybind.key} + ${keybind.description} +
+ `; + } + } + + // Resize/vanish keybinds + if (resize_binds.length > 0) { + keybinds_html += "
Annotation Display
"; + for (const keybind of resize_binds) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + keybinds_html += ` +
+ ${keybind.key} + ${keybind.description} +
+ `; + } + } + + // Other keybinds + if (other.length > 0) { + keybinds_html += "
Other
"; + for (const keybind of other) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + keybinds_html += ` +
+ ${keybind.key} + ${keybind.description} +
+ `; + } + } + + return ` +
+
+

Keybinds

+ +
+
+
+ ${keybinds_html} +
+
+
+ `; + } + + /** + * Returns a unique string identifier for this toolbox item type + */ + public get_toolbox_item_type(): string { + return "keybinds"; + } + + /** + * Called after ULabel initialization is complete + */ + public after_init(): void { + this.add_event_listeners(); + } + + /** + * Add event listeners for the keybinds toolbox item + */ + private add_event_listeners(): void { + // Toggle collapse/expand + $(document).on("click.ulabel", ".keybinds-header", () => { + this.is_collapsed = !this.is_collapsed; + const content = $(".keybinds-content"); + const toggle_btn = $(".keybinds-toggle-btn"); + + if (this.is_collapsed) { + content.removeClass("expanded"); + toggle_btn.text("▶"); + } else { + content.addClass("expanded"); + toggle_btn.text("▼"); + } + }); + + // TODO: Implement edit functionality for configurable keybinds + $(document).on("click.ulabel", ".keybind-key.editable", (e) => { + const target = $(e.currentTarget); + const config_key = target.data("config-key"); + + // For now, just show an alert + alert(`Editing keybind for: ${config_key}\n\nThis feature is not yet fully implemented.`); + }); + } +} From b9d76dbbe16fc0c03bfd4143123ccf6ea3865d61 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 12:26:48 -0500 Subject: [PATCH 04/43] labels vs titles --- .github/tasks.md | 2 +- src/toolbox_items/keybinds.ts | 63 +++++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 5c671a97..46cac1d0 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -12,7 +12,7 @@ - [x] Research existing keybinds in the codebase - [x] Create basic keybinds toolbox item file - [x] Register keybinds toolbox item in configuration - - [ ] Display list of all keybinds (the key) labeled with the name + - [x] Display list of all keybinds (the key) labeled with the name - [x] Add hover tooltips with detailed descriptions (using title attribute) - [x] Add collision detection and red highlighting - [ ] Implement editing for configurable keybinds diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 13c39173..9dea6938 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -3,6 +3,7 @@ import { ToolboxItem } from "../toolbox"; interface KeybindInfo { key: string; + label: string; description: string; configurable: boolean; config_key?: string; @@ -96,14 +97,27 @@ export class KeybindsToolboxItem extends ToolboxItem { background-color: rgba(0, 128, 255, 0.1); } + #toolbox .keybind-description { + flex: 1; + margin-right: 0.75rem; + color: #333; + font-weight: 500; + } + + .ulabel-night #toolbox .keybind-description { + color: #ddd; + } + #toolbox .keybind-key { font-family: monospace; font-weight: bold; - padding: 0.25rem 0.5rem; + font-size: 0.9rem; + padding: 0.3rem 0.6rem; background-color: rgba(0, 0, 0, 0.1); - border-radius: 3px; - min-width: 40px; + border-radius: 4px; + min-width: 30px; text-align: center; + white-space: nowrap; } .ulabel-night #toolbox .keybind-key { @@ -117,20 +131,16 @@ export class KeybindsToolboxItem extends ToolboxItem { #toolbox .keybind-key.editable { cursor: pointer; + border: 1px solid transparent; } - #toolbox .keybind-key.editing { - outline: 2px solid rgba(0, 128, 255, 0.7); + #toolbox .keybind-key.editable:hover { + background-color: rgba(0, 128, 255, 0.2); + border-color: rgba(0, 128, 255, 0.5); } - #toolbox .keybind-description { - flex: 1; - margin-left: 0.75rem; - color: #666; - } - - .ulabel-night #toolbox .keybind-description { - color: #aaa; + #toolbox .keybind-key.editing { + outline: 2px solid rgba(0, 128, 255, 0.7); } #toolbox .keybind-category { @@ -165,6 +175,7 @@ export class KeybindsToolboxItem extends ToolboxItem { // Configurable keybinds keybinds.push({ key: config.change_zoom_keybind, + label: "Change Zoom", description: "Change zoom mode (hold: drag zoom, release: single-click zoom)", configurable: true, config_key: "change_zoom_keybind", @@ -172,6 +183,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.create_point_annotation_keybind, + label: "Create Point", description: "Create point annotation at mouse location (in point mode)", configurable: true, config_key: "create_point_annotation_keybind", @@ -179,6 +191,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.delete_annotation_keybind, + label: "Delete Annotation", description: "Delete (deprecate) the active annotation", configurable: true, config_key: "delete_annotation_keybind", @@ -186,6 +199,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.switch_subtask_keybind, + label: "Switch Subtask", description: "Switch to the next subtask", configurable: true, config_key: "switch_subtask_keybind", @@ -193,6 +207,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_annotation_mode_keybind, + label: "Toggle Mode", description: "Toggle between annotation modes", configurable: true, config_key: "toggle_annotation_mode_keybind", @@ -200,6 +215,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.create_bbox_on_initial_crop, + label: "Create BBox on Crop", description: "Create bbox annotation on initial crop area", configurable: true, config_key: "create_bbox_on_initial_crop", @@ -207,6 +223,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_brush_mode_keybind, + label: "Toggle Brush", description: "Toggle brush mode for polygon/contour annotation", configurable: true, config_key: "toggle_brush_mode_keybind", @@ -214,6 +231,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_erase_mode_keybind, + label: "Toggle Erase", description: "Toggle erase mode in brush", configurable: true, config_key: "toggle_erase_mode_keybind", @@ -221,6 +239,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.increase_brush_size_keybind, + label: "Increase Brush Size", description: "Increase brush size", configurable: true, config_key: "increase_brush_size_keybind", @@ -228,6 +247,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.decrease_brush_size_keybind, + label: "Decrease Brush Size", description: "Decrease brush size", configurable: true, config_key: "decrease_brush_size_keybind", @@ -235,6 +255,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.fly_to_next_annotation_keybind, + label: "Next Annotation", description: "Fly to next annotation", configurable: true, config_key: "fly_to_next_annotation_keybind", @@ -243,6 +264,7 @@ export class KeybindsToolboxItem extends ToolboxItem { if (config.fly_to_previous_annotation_keybind !== null) { keybinds.push({ key: config.fly_to_previous_annotation_keybind, + label: "Previous Annotation", description: "Fly to previous annotation", configurable: true, config_key: "fly_to_previous_annotation_keybind", @@ -253,30 +275,35 @@ export class KeybindsToolboxItem extends ToolboxItem { if (config.default_keybinds) { keybinds.push({ key: config.default_keybinds.annotation_size_small, + label: "Size: Small", description: "Set annotation size to small", configurable: false, }); keybinds.push({ key: config.default_keybinds.annotation_size_large, + label: "Size: Large", description: "Set annotation size to large", configurable: false, }); keybinds.push({ key: config.default_keybinds.annotation_size_plus, + label: "Size: Increase", description: "Increase annotation size", configurable: false, }); keybinds.push({ key: config.default_keybinds.annotation_size_minus, + label: "Size: Decrease", description: "Decrease annotation size", configurable: false, }); keybinds.push({ key: config.default_keybinds.annotation_vanish, + label: "Toggle Vanish", description: "Toggle annotation vanish mode", configurable: false, }); @@ -289,6 +316,7 @@ export class KeybindsToolboxItem extends ToolboxItem { if (class_def.keybind !== null) { keybinds.push({ key: class_def.keybind, + label: class_def.name, description: `Select class: ${class_def.name}`, configurable: false, }); @@ -299,6 +327,7 @@ export class KeybindsToolboxItem extends ToolboxItem { // Non-configurable keybinds (hardcoded in listeners) keybinds.push({ key: "Shift+Tab", + label: "Previous Annotation", description: "Fly to previous annotation (if fly_to_previous_annotation_keybind is null)", configurable: false, }); @@ -343,8 +372,8 @@ export class KeybindsToolboxItem extends ToolboxItem { const collision_class = has_collision ? " collision" : ""; keybinds_html += `
+ ${keybind.label} ${keybind.key} - ${keybind.description}
`; } @@ -358,8 +387,8 @@ export class KeybindsToolboxItem extends ToolboxItem { const collision_class = has_collision ? " collision" : ""; keybinds_html += `
+ ${keybind.label} ${keybind.key} - ${keybind.description}
`; } @@ -373,8 +402,8 @@ export class KeybindsToolboxItem extends ToolboxItem { const collision_class = has_collision ? " collision" : ""; keybinds_html += `
+ ${keybind.label} ${keybind.key} - ${keybind.description}
`; } @@ -388,8 +417,8 @@ export class KeybindsToolboxItem extends ToolboxItem { const collision_class = has_collision ? " collision" : ""; keybinds_html += `
+ ${keybind.label} ${keybind.key} - ${keybind.description}
`; } From 68fe3ab0c73da906bd13db7aa519d02c96bcd9c2 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 12:44:32 -0500 Subject: [PATCH 05/43] fix css clash --- src/toolbox_items/keybinds.ts | 189 +++++++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 12 deletions(-) diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 9dea6938..5b5abc29 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -87,6 +87,7 @@ export class KeybindsToolboxItem extends ToolboxItem { border-radius: 4px; background-color: rgba(0, 0, 0, 0.05); font-size: 0.85rem; + gap: 0.5rem; } .ulabel-night #toolbox .keybind-item { @@ -98,10 +99,13 @@ export class KeybindsToolboxItem extends ToolboxItem { } #toolbox .keybind-description { - flex: 1; + flex: 0 1 auto; margin-right: 0.75rem; color: #333; font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .ulabel-night #toolbox .keybind-description { @@ -118,6 +122,7 @@ export class KeybindsToolboxItem extends ToolboxItem { min-width: 30px; text-align: center; white-space: nowrap; + flex-shrink: 0; } .ulabel-night #toolbox .keybind-key { @@ -129,12 +134,12 @@ export class KeybindsToolboxItem extends ToolboxItem { border: 1px solid red; } - #toolbox .keybind-key.editable { + #toolbox .keybind-key.keybind-editable { cursor: pointer; border: 1px solid transparent; } - #toolbox .keybind-key.editable:hover { + #toolbox .keybind-key.keybind-editable:hover { background-color: rgba(0, 128, 255, 0.2); border-color: rgba(0, 128, 255, 0.5); } @@ -370,10 +375,11 @@ export class KeybindsToolboxItem extends ToolboxItem { for (const keybind of configurable) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; keybinds_html += `
${keybind.label} - ${keybind.key} + ${display_key}
`; } @@ -385,10 +391,11 @@ export class KeybindsToolboxItem extends ToolboxItem { for (const keybind of class_binds) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; keybinds_html += `
${keybind.label} - ${keybind.key} + ${display_key}
`; } @@ -400,10 +407,11 @@ export class KeybindsToolboxItem extends ToolboxItem { for (const keybind of resize_binds) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; keybinds_html += `
${keybind.label} - ${keybind.key} + ${display_key}
`; } @@ -415,10 +423,11 @@ export class KeybindsToolboxItem extends ToolboxItem { for (const keybind of other) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; keybinds_html += `
${keybind.label} - ${keybind.key} + ${display_key}
`; } @@ -472,13 +481,169 @@ export class KeybindsToolboxItem extends ToolboxItem { } }); - // TODO: Implement edit functionality for configurable keybinds - $(document).on("click.ulabel", ".keybind-key.editable", (e) => { + // Edit functionality for configurable keybinds + $(document).on("click.ulabel", ".keybind-key.keybind-editable", (e) => { + e.stopPropagation(); const target = $(e.currentTarget); - const config_key = target.data("config-key"); + const config_key = target.data("config-key") as string; + + // If already editing this key, do nothing + if (target.hasClass("editing")) { + return; + } + + // Remove editing class from any other key + $(".keybind-key.editing").removeClass("editing"); + + // Add editing class to this key + target.addClass("editing"); + target.text("Press key..."); + + // Create a one-time keydown handler to capture the new key + const keyHandler = (keyEvent: JQuery.KeyDownEvent) => { + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + + let new_key = keyEvent.key; + + // Handle special keys + if (new_key === "Escape") { + // Cancel editing + target.removeClass("editing"); + target.text(this.ulabel.config[config_key]); + $(document).off("keydown.keybind-edit"); + return; + } + + // Normalize key names + if (new_key === " ") { + new_key = "Space"; + } else if (new_key.length === 1) { + // Keep single character keys lowercase for consistency + new_key = new_key.toLowerCase(); + } + + // Update the config + this.ulabel.config[config_key] = new_key; - // For now, just show an alert - alert(`Editing keybind for: ${config_key}\n\nThis feature is not yet fully implemented.`); + // Update the display + target.removeClass("editing"); + target.text(new_key); + + // Refresh the entire keybinds list to update collision detection + this.refresh_keybinds_display(); + + // Remove the keydown handler + $(document).off("keydown.keybind-edit"); + }; + + // Attach the keydown handler + $(document).on("keydown.keybind-edit", keyHandler); + }); + + // Click outside to cancel editing + $(document).on("click.ulabel", (e) => { + if (!$(e.target).hasClass("keybind-key")) { + const editing_key = $(".keybind-key.editing"); + if (editing_key.length > 0) { + const config_key = editing_key.data("config-key") as string; + editing_key.removeClass("editing"); + editing_key.text(this.ulabel.config[config_key]); + $(document).off("keydown.keybind-edit"); + } + } }); } + + /** + * Refresh the keybinds display to show updated keys and collision detection + */ + private refresh_keybinds_display(): void { + const keybinds_list = $(".keybinds-list"); + if (keybinds_list.length === 0) { + return; + } + + const all_keybinds = this.get_all_keybinds(); + let keybinds_html = ""; + + // Group keybinds by category + const configurable = all_keybinds.filter((kb) => kb.configurable); + const class_binds = all_keybinds.filter((kb) => kb.description.startsWith("Select class:")); + const resize_binds = all_keybinds.filter((kb) => + kb.description.includes("annotation size") || kb.description.includes("vanish"), + ); + const other = all_keybinds.filter((kb) => + !kb.configurable && + !kb.description.startsWith("Select class:") && + !kb.description.includes("annotation size") && + !kb.description.includes("vanish"), + ); + + // Configurable keybinds + if (configurable.length > 0) { + keybinds_html += "
Configurable Keybinds
"; + for (const keybind of configurable) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + keybinds_html += ` +
+ ${keybind.label} + ${display_key} +
+ `; + } + } + + // Class keybinds + if (class_binds.length > 0) { + keybinds_html += "
Class Selection
"; + for (const keybind of class_binds) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + keybinds_html += ` +
+ ${keybind.label} + ${display_key} +
+ `; + } + } + + // Resize/vanish keybinds + if (resize_binds.length > 0) { + keybinds_html += "
Annotation Display
"; + for (const keybind of resize_binds) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + keybinds_html += ` +
+ ${keybind.label} + ${display_key} +
+ `; + } + } + + // Other keybinds + if (other.length > 0) { + keybinds_html += "
Other
"; + for (const keybind of other) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + keybinds_html += ` +
+ ${keybind.label} + ${display_key} +
+ `; + } + } + + keybinds_list.html(keybinds_html); + } } From 723d9b63a743eb2c35dbd2ff94cf55c7c33259a8 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 12:54:57 -0500 Subject: [PATCH 06/43] add flag while editing keybinds --- .github/tasks.md | 2 +- index.d.ts | 2 ++ src/index.js | 3 +++ src/listeners.ts | 10 ++++++++++ src/toolbox_items/keybinds.ts | 9 ++++++++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 46cac1d0..65c43d19 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -15,7 +15,7 @@ - [x] Display list of all keybinds (the key) labeled with the name - [x] Add hover tooltips with detailed descriptions (using title attribute) - [x] Add collision detection and red highlighting - - [ ] Implement editing for configurable keybinds + - [x] Implement editing for configurable keybinds - [ ] Test functionality - [ ] Add support for keybind "chords" (ie, "shift+i") diff --git a/index.d.ts b/index.d.ts index dc76cb0c..68a5e619 100644 --- a/index.d.ts +++ b/index.d.ts @@ -252,6 +252,8 @@ export class ULabel { last_brush_stroke: [number, number]; line_size: number; anno_scaling_mode: AnnoScalingMode; + // Keybind editing state + is_editing_keybind: boolean; // Render state // TODO (joshua-dean): this is never assigned, is it used? demo_canvas_context: CanvasRenderingContext2D; diff --git a/src/index.js b/src/index.js index 7389eaa4..9109230e 100644 --- a/src/index.js +++ b/src/index.js @@ -587,6 +587,9 @@ export class ULabel { line_size: this.config.initial_line_size, anno_scaling_mode: this.config.anno_scaling_mode, + // Keybind editing state + is_editing_keybind: false, + // Renderings state demo_canvas_context: null, edited: false, diff --git a/src/listeners.ts b/src/listeners.ts index c5714b27..bac86872 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -23,6 +23,11 @@ function handle_keypress_event( keypress_event: JQuery.KeyPressEvent, ulabel: ULabel, ) { + // Don't handle keypresses if editing a keybind + if (ulabel.state.is_editing_keybind) { + return; + } + const current_subtask = ulabel.get_current_subtask(); switch (keypress_event.key) { // Create a point annotation at the mouse's current location @@ -204,6 +209,11 @@ function handle_keydown_event( keydown_event: JQuery.KeyDownEvent, ulabel: ULabel, ): boolean { + // Don't handle keydown events if editing a keybind + if (ulabel.state.is_editing_keybind) { + return false; + } + const shift = keydown_event.shiftKey; const ctrl = keydown_event.ctrlKey || keydown_event.metaKey; const key_is_z = ( diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 5b5abc29..cfe8bd56 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -499,10 +499,14 @@ export class KeybindsToolboxItem extends ToolboxItem { target.addClass("editing"); target.text("Press key..."); + // Set the editing flag to prevent other key handlers from firing + this.ulabel.state.is_editing_keybind = true; + // Create a one-time keydown handler to capture the new key const keyHandler = (keyEvent: JQuery.KeyDownEvent) => { keyEvent.preventDefault(); keyEvent.stopPropagation(); + keyEvent.stopImmediatePropagation(); let new_key = keyEvent.key; @@ -512,6 +516,7 @@ export class KeybindsToolboxItem extends ToolboxItem { target.removeClass("editing"); target.text(this.ulabel.config[config_key]); $(document).off("keydown.keybind-edit"); + this.ulabel.state.is_editing_keybind = false; return; } @@ -533,8 +538,9 @@ export class KeybindsToolboxItem extends ToolboxItem { // Refresh the entire keybinds list to update collision detection this.refresh_keybinds_display(); - // Remove the keydown handler + // Remove the keydown handler and clear editing flag $(document).off("keydown.keybind-edit"); + this.ulabel.state.is_editing_keybind = false; }; // Attach the keydown handler @@ -550,6 +556,7 @@ export class KeybindsToolboxItem extends ToolboxItem { editing_key.removeClass("editing"); editing_key.text(this.ulabel.config[config_key]); $(document).off("keydown.keybind-edit"); + this.ulabel.state.is_editing_keybind = false; } } }); From ed76d51220734944f34bde0efc7323273651746b Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 13:47:58 -0500 Subject: [PATCH 07/43] handle chords --- .github/tasks.md | 13 +- src/listeners.ts | 314 ++++++++++++++++++++-------------- src/toolbox_items/keybinds.ts | 91 ++++++++-- 3 files changed, 276 insertions(+), 142 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 65c43d19..3a4a6af8 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -8,7 +8,7 @@ - [x] Move arrow to top of toolbox (instead of middle) - [x] Make annbox expand when toolbox is collapsed - [x] Make collapsed button visible -- [ ] Create a keybinds toolbox item +- [x] Create a keybinds toolbox item - [x] Research existing keybinds in the codebase - [x] Create basic keybinds toolbox item file - [x] Register keybinds toolbox item in configuration @@ -16,7 +16,16 @@ - [x] Add hover tooltips with detailed descriptions (using title attribute) - [x] Add collision detection and red highlighting - [x] Implement editing for configurable keybinds - - [ ] Test functionality + - [x] Test functionality - [ ] Add support for keybind "chords" (ie, "shift+i") + - [x] Update keybind edit handler to capture modifier keys (shift, ctrl, alt) + - [x] Create chord string format (e.g., "shift+i", "ctrl+alt+d") + - [x] Update key comparison logic in listeners to support chords + - [x] Update display to show chords properly (displays captured chord automatically) + - [ ] Test chord functionality +- [ ] Store collapse/expand for applicable toolbox items + - [ ] Keybinds + - [ ] Annotation List + - [ ] Image Filters diff --git a/src/listeners.ts b/src/listeners.ts index bac86872..d6b86e00 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -13,6 +13,42 @@ import { set_local_storage_item } from "./utilities"; const ULABEL_NAMESPACE = ".ulabel"; +/** + * Check if a keyboard event matches a keybind (supports chords like "ctrl+s") + */ +function event_matches_keybind( + keyEvent: JQuery.KeyDownEvent | JQuery.KeyPressEvent, + keybind: string, +): boolean { + if (!keybind) { + return false; + } + + // Check if this is a chord (contains '+') + if (keybind.includes("+")) { + const parts = keybind.toLowerCase().split("+"); + const modifiers = new Set(parts.slice(0, -1)); + const key = parts[parts.length - 1]; + + // Check modifiers + const has_ctrl = (keyEvent.ctrlKey || keyEvent.metaKey) === modifiers.has("ctrl"); + const has_alt = keyEvent.altKey === modifiers.has("alt"); + const has_shift = keyEvent.shiftKey === modifiers.has("shift"); + + // Normalize event key + let event_key = keyEvent.key.toLowerCase(); + if (event_key === " ") { + event_key = "space"; + } + + return has_ctrl && has_alt && has_shift && event_key === key; + } + + // Simple key match (no modifiers allowed for simple keys) + const no_modifiers = !keyEvent.ctrlKey && !keyEvent.metaKey && !keyEvent.altKey && !keyEvent.shiftKey; + return no_modifiers && (keyEvent.key === keybind || keyEvent.key.toLowerCase() === keybind.toLowerCase()); +} + /** * Handle keypress events. * @@ -29,103 +65,117 @@ function handle_keypress_event( } const current_subtask = ulabel.get_current_subtask(); - switch (keypress_event.key) { - // Create a point annotation at the mouse's current location - case ulabel.config.create_point_annotation_keybind: - // Only allow keypress to create point annotations - if (current_subtask.state.annotation_mode === "point") { - // Create an annotation based on the last mouse position - ulabel.create_point_annotation_at_mouse_location(); - } - break; - // Create a bbox annotation around the initial_crop, - // or the whole image if inital_crop does not exist - case ulabel.config.create_bbox_on_initial_crop: - if (current_subtask.state.annotation_mode === "bbox") { - // Default to an annotation with size of image - // Create the coordinates for the bbox's spatial payload - let bbox_top_left: [number, number] = [0, 0]; - let bbox_bottom_right: [number, number] = [ - ulabel.config.image_width, - ulabel.config.image_height, - ]; - - // If an initial crop exists, use that instead - // TODO (joshua-dean): can't this just be "if (ulabel.config.initial_crop)"? - if (ulabel.config.initial_crop !== null && ulabel.config.initial_crop !== undefined) { - // Convenience - const initial_crop = ulabel.config.initial_crop; - - // Create the coordinates for the bbox's spatial payload - bbox_top_left = [initial_crop.left, initial_crop.top]; - bbox_bottom_right = [initial_crop.left + initial_crop.width, initial_crop.top + initial_crop.height]; - } - // Create the annotation - ulabel.create_annotation( - current_subtask.state.annotation_mode, - [bbox_top_left, bbox_bottom_right], - ); + // Create a point annotation at the mouse's current location + if (event_matches_keybind(keypress_event, ulabel.config.create_point_annotation_keybind)) { + // Only allow keypress to create point annotations + if (current_subtask.state.annotation_mode === "point") { + // Create an annotation based on the last mouse position + ulabel.create_point_annotation_at_mouse_location(); + } + return; + } + + // Create a bbox annotation around the initial_crop, + // or the whole image if inital_crop does not exist + if (event_matches_keybind(keypress_event, ulabel.config.create_bbox_on_initial_crop)) { + if (current_subtask.state.annotation_mode === "bbox") { + // Default to an annotation with size of image + // Create the coordinates for the bbox's spatial payload + let bbox_top_left: [number, number] = [0, 0]; + let bbox_bottom_right: [number, number] = [ + ulabel.config.image_width, + ulabel.config.image_height, + ]; + + // If an initial crop exists, use that instead + // TODO (joshua-dean): can't this just be "if (ulabel.config.initial_crop)"? + if (ulabel.config.initial_crop !== null && ulabel.config.initial_crop !== undefined) { + // Convenience + const initial_crop = ulabel.config.initial_crop; + + // Create the coordinates for the bbox's spatial payload + bbox_top_left = [initial_crop.left, initial_crop.top]; + bbox_bottom_right = [initial_crop.left + initial_crop.width, initial_crop.top + initial_crop.height]; } - break; - // Change to brush mode (for now, polygon only) - case ulabel.config.toggle_brush_mode_keybind: - ulabel.toggle_brush_mode(ulabel.state["last_move"]); - break; - // Change to erase mode (will also set the is_in_brush_mode state) - case ulabel.config.toggle_erase_mode_keybind: - ulabel.toggle_erase_mode(ulabel.state["last_move"]); - break; - // Increase brush size by 10% - case ulabel.config.increase_brush_size_keybind: - ulabel.change_brush_size(1.1); - break; - // Decrease brush size by 10% - case ulabel.config.decrease_brush_size_keybind: - ulabel.change_brush_size(1 / 1.1); - break; - case ulabel.config.change_zoom_keybind.toLowerCase(): - ulabel.show_initial_crop(); - break; - case ulabel.config.change_zoom_keybind.toUpperCase(): - ulabel.show_whole_image(); - break; - default: - // TODO (joshua-dean): break this out - if (!DELETE_MODES.includes(current_subtask.state.spatial_type)) { - // Check for class keybinds - for (let i = 0; i < current_subtask.class_defs.length; i++) { - const class_def = current_subtask.class_defs[i]; - if (class_def.keybind !== null && keypress_event.key === class_def.keybind) { - const st_key = ulabel.get_current_subtask_key(); - const class_button = $(`#tb-id-app--${st_key} a.tbid-opt`).eq(i); - if (class_button.hasClass("sel")) { - // If the class button is already selected, - // check if there is an active annotation, and if so, get it - let target_id = null; - if (current_subtask.state.active_id !== null) { - target_id = current_subtask.state.active_id; - } else if (current_subtask.state.move_candidate !== null) { - target_id = current_subtask.state.move_candidate["annid"]; - } - // Update the class of the active annotation - if (target_id !== null) { - // Set the annotation's class to the selected class - ulabel.handle_id_dialog_click( - ulabel.state["last_move"], - target_id, - ulabel.get_active_class_id_idx(), - ); - } - } else { - // Click the class button if not already selected - class_button.trigger("click"); - } - return; + + // Create the annotation + ulabel.create_annotation( + current_subtask.state.annotation_mode, + [bbox_top_left, bbox_bottom_right], + ); + } + return; + } + + // Change to brush mode (for now, polygon only) + if (event_matches_keybind(keypress_event, ulabel.config.toggle_brush_mode_keybind)) { + ulabel.toggle_brush_mode(ulabel.state["last_move"]); + return; + } + + // Change to erase mode (will also set the is_in_brush_mode state) + if (event_matches_keybind(keypress_event, ulabel.config.toggle_erase_mode_keybind)) { + ulabel.toggle_erase_mode(ulabel.state["last_move"]); + return; + } + + // Increase brush size by 10% + if (event_matches_keybind(keypress_event, ulabel.config.increase_brush_size_keybind)) { + ulabel.change_brush_size(1.1); + return; + } + + // Decrease brush size by 10% + if (event_matches_keybind(keypress_event, ulabel.config.decrease_brush_size_keybind)) { + ulabel.change_brush_size(1 / 1.1); + return; + } + + // Show initial crop (lowercase change zoom keybind) + if (event_matches_keybind(keypress_event, ulabel.config.change_zoom_keybind.toLowerCase())) { + ulabel.show_initial_crop(); + return; + } + + // Show whole image (uppercase change zoom keybind) + if (event_matches_keybind(keypress_event, ulabel.config.change_zoom_keybind.toUpperCase())) { + ulabel.show_whole_image(); + return; + } + + // Check for class keybinds + if (!DELETE_MODES.includes(current_subtask.state.spatial_type)) { + for (let i = 0; i < current_subtask.class_defs.length; i++) { + const class_def = current_subtask.class_defs[i]; + if (class_def.keybind !== null && event_matches_keybind(keypress_event, class_def.keybind)) { + const st_key = ulabel.get_current_subtask_key(); + const class_button = $(`#tb-id-app--${st_key} a.tbid-opt`).eq(i); + if (class_button.hasClass("sel")) { + // If the class button is already selected, + // check if there is an active annotation, and if so, get it + let target_id = null; + if (current_subtask.state.active_id !== null) { + target_id = current_subtask.state.active_id; + } else if (current_subtask.state.move_candidate !== null) { + target_id = current_subtask.state.move_candidate["annid"]; } + // Update the class of the active annotation + if (target_id !== null) { + // Set the annotation's class to the selected class + ulabel.handle_id_dialog_click( + ulabel.state["last_move"], + target_id, + ulabel.get_active_class_id_idx(), + ); + } + } else { + // Click the class button if not already selected + class_button.trigger("click"); } + return; } - break; + } } } @@ -230,41 +280,47 @@ function handle_keydown_event( ulabel.undo(); } return false; - } else { - const current_subtask = ulabel.get_current_subtask(); - switch (keydown_event.key.toLowerCase()) { - case "escape": - // If in erase or brush mode, cancel the brush - if (current_subtask.state.is_in_erase_mode) { - ulabel.toggle_erase_mode(); - } else if (current_subtask.state.is_in_brush_mode) { - ulabel.toggle_brush_mode(); - } else if (current_subtask.state.starting_complex_polygon) { - // If starting a complex polygon, undo - ulabel.undo(); - } else if (current_subtask.state.is_in_progress) { - // If in the middle of drawing an annotation, cancel the annotation - ulabel.cancel_annotation(); - } - break; - case ulabel.config.fly_to_next_annotation_keybind.toLowerCase(): - // For 'tab', prevent default - if (keydown_event.key.toLowerCase() === "tab") { - keydown_event.preventDefault(); - } + } - if (ulabel.config.fly_to_previous_annotation_keybind === null && shift) { - ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); - } else if (!shift) { - ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom); - } - break; - case ulabel.config.fly_to_previous_annotation_keybind.toLowerCase(): - if (ulabel.config.fly_to_previous_annotation_keybind !== null) { - ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); - } - break; + const current_subtask = ulabel.get_current_subtask(); + + // Handle Escape key + if (keydown_event.key.toLowerCase() === "escape") { + // If in erase or brush mode, cancel the brush + if (current_subtask.state.is_in_erase_mode) { + ulabel.toggle_erase_mode(); + } else if (current_subtask.state.is_in_brush_mode) { + ulabel.toggle_brush_mode(); + } else if (current_subtask.state.starting_complex_polygon) { + // If starting a complex polygon, undo + ulabel.undo(); + } else if (current_subtask.state.is_in_progress) { + // If in the middle of drawing an annotation, cancel the annotation + ulabel.cancel_annotation(); } + return false; + } + + // Handle fly to next annotation + if (event_matches_keybind(keydown_event, ulabel.config.fly_to_next_annotation_keybind)) { + // For 'tab', prevent default + if (keydown_event.key.toLowerCase() === "tab") { + keydown_event.preventDefault(); + } + + if (ulabel.config.fly_to_previous_annotation_keybind === null && shift) { + ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); + } else if (!shift) { + ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom); + } + return false; + } + + // Handle fly to previous annotation + if (ulabel.config.fly_to_previous_annotation_keybind !== null && + event_matches_keybind(keydown_event, ulabel.config.fly_to_previous_annotation_keybind)) { + ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); + return false; } } @@ -396,12 +452,12 @@ export function create_ulabel_listeners( // Keybind to switch active subtask $(document).on( "keypress" + ULABEL_NAMESPACE, - (keypress_event) => { + (keypress_event: JQuery.KeyPressEvent) => { // Ignore if in the middle of annotation if (ulabel.get_current_subtask()["state"]["is_in_progress"]) return; // Check for the right keypress - if (keypress_event.key === ulabel.config.switch_subtask_keybind) { + if (event_matches_keybind(keypress_event, ulabel.config.switch_subtask_keybind)) { ulabel.switch_to_next_subtask(); } }, @@ -500,9 +556,9 @@ export function create_ulabel_listeners( $(document).on( "keypress" + ULABEL_NAMESPACE, - (keypress_event) => { + (keypress_event: JQuery.KeyPressEvent) => { // Check the key pressed against the delete annotation keybind in the config - if (keypress_event.key === ulabel.config.delete_annotation_keybind) { + if (event_matches_keybind(keypress_event, ulabel.config.delete_annotation_keybind)) { // Check the edit_candidate to make sure its not null and isn't nonspatial const edit_cand = ulabel.get_current_subtask().state.edit_candidate; if (edit_cand !== null && !NONSPATIAL_MODES.includes(edit_cand.spatial_type)) { diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index cfe8bd56..d5a9204b 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -462,6 +462,78 @@ export class KeybindsToolboxItem extends ToolboxItem { this.add_event_listeners(); } + /** + * Build a keybind chord string from a keyboard event + */ + private build_chord_string(keyEvent: JQuery.KeyDownEvent): string { + const modifiers: string[] = []; + const key = keyEvent.key; + + // Don't treat modifier keys themselves as part of the chord + if (key === "Control" || key === "Shift" || key === "Alt" || key === "Meta") { + return null; + } + + // Build modifier list in a consistent order + if (keyEvent.ctrlKey || keyEvent.metaKey) { + modifiers.push("ctrl"); + } + if (keyEvent.altKey) { + modifiers.push("alt"); + } + if (keyEvent.shiftKey) { + modifiers.push("shift"); + } + + // Normalize the key name + let normalized_key = key; + if (key === " ") { + normalized_key = "space"; + } else if (key.length === 1) { + // Keep single character keys lowercase for consistency + normalized_key = key.toLowerCase(); + } + + // If there are modifiers, build a chord string + if (modifiers.length > 0) { + return modifiers.join("+") + "+" + normalized_key; + } + + return normalized_key; + } + + /** + * Check if a keyboard event matches a keybind (supports chords) + */ + private event_matches_keybind(keyEvent: JQuery.KeyDownEvent | JQuery.KeyPressEvent, keybind: string): boolean { + if (!keybind) { + return false; + } + + // Check if this is a chord (contains '+') + if (keybind.includes("+")) { + const parts = keybind.toLowerCase().split("+"); + const modifiers = new Set(parts.slice(0, -1)); + const key = parts[parts.length - 1]; + + // Check modifiers + const has_ctrl = (keyEvent.ctrlKey || keyEvent.metaKey) === modifiers.has("ctrl"); + const has_alt = keyEvent.altKey === modifiers.has("alt"); + const has_shift = keyEvent.shiftKey === modifiers.has("shift"); + + // Normalize event key + let event_key = keyEvent.key.toLowerCase(); + if (event_key === " ") { + event_key = "space"; + } + + return has_ctrl && has_alt && has_shift && event_key === key; + } + + // Simple key match (no modifiers) + return keyEvent.key === keybind || keyEvent.key.toLowerCase() === keybind.toLowerCase(); + } + /** * Add event listeners for the keybinds toolbox item */ @@ -508,11 +580,8 @@ export class KeybindsToolboxItem extends ToolboxItem { keyEvent.stopPropagation(); keyEvent.stopImmediatePropagation(); - let new_key = keyEvent.key; - - // Handle special keys - if (new_key === "Escape") { - // Cancel editing + // Handle Escape to cancel + if (keyEvent.key === "Escape") { target.removeClass("editing"); target.text(this.ulabel.config[config_key]); $(document).off("keydown.keybind-edit"); @@ -520,12 +589,12 @@ export class KeybindsToolboxItem extends ToolboxItem { return; } - // Normalize key names - if (new_key === " ") { - new_key = "Space"; - } else if (new_key.length === 1) { - // Keep single character keys lowercase for consistency - new_key = new_key.toLowerCase(); + // Build chord string (handles modifiers + key) + const new_key = this.build_chord_string(keyEvent); + + // If null (user pressed only a modifier key), ignore + if (new_key === null) { + return; } // Update the config From 6533e9f27fa0a161175322f2678348d247c24b85 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 13:58:35 -0500 Subject: [PATCH 08/43] update all keybinds to use the chord logic --- .github/tasks.md | 8 ++++++-- api_spec.md | 4 ++-- src/configuration.ts | 5 ++--- src/listeners.ts | 16 ++++------------ src/toolbox_items/keybinds.ts | 24 +++++++----------------- 5 files changed, 21 insertions(+), 36 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 3a4a6af8..48a83fac 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -17,15 +17,19 @@ - [x] Add collision detection and red highlighting - [x] Implement editing for configurable keybinds - [x] Test functionality -- [ ] Add support for keybind "chords" (ie, "shift+i") +- [x] Add support for keybind "chords" (ie, "shift+i") - [x] Update keybind edit handler to capture modifier keys (shift, ctrl, alt) - [x] Create chord string format (e.g., "shift+i", "ctrl+alt+d") - [x] Update key comparison logic in listeners to support chords - [x] Update display to show chords properly (displays captured chord automatically) - - [ ] Test chord functionality + - [x] Test chord functionality - [ ] Store collapse/expand for applicable toolbox items - [ ] Keybinds - [ ] Annotation List - [ ] Image Filters +- [ ] Make all keybinds configurable +- [ ] Minor changes to existing keybinds + - [ ] Rename "Change Zoom" keybind to "Reset Zoom" + - [ ] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" diff --git a/api_spec.md b/api_spec.md index e7ada40a..b00e8166 100644 --- a/api_spec.md +++ b/api_spec.md @@ -73,7 +73,7 @@ class ULabel({ increase_brush_size_keybind: string, decrease_brush_size_keybind: string, fly_to_next_annotation_keybind: string, - fly_to_previous_annotation_keybind: string | null, + fly_to_previous_annotation_keybind: string, fly_to_max_zoom: number, n_annos_per_canvas: number }) @@ -480,7 +480,7 @@ Keybind to decrease the brush size. Default is `[`. Requires the active subtask Keybind to set the zoom to focus on the next annotation. Default is `Tab`, which also will disable any default browser behavior for `Tab`. ### `fly_to_previous_annotation_keybind` -Keybind to set the zoom to focus on the previous annotation. Default is ``, which will default to `Shift+`. +Keybind to set the zoom to focus on the previous annotation. Default is `shift+tab`. Supports chord keybinds (e.g., `shift+p`, `ctrl+alt+n`). ### `fly_to_max_zoom` Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`. diff --git a/src/configuration.ts b/src/configuration.ts index 315a945e..32b4405b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -201,10 +201,9 @@ export class Configuration { public decrease_brush_size_keybind: string = "["; - public fly_to_next_annotation_keybind: string = "Tab"; + public fly_to_next_annotation_keybind: string = "tab"; - // null -> Shift+fly_to_next_annotation_keybind - public fly_to_previous_annotation_keybind: string | null = null; + public fly_to_previous_annotation_keybind: string = "shift+tab"; public fly_to_max_zoom: number = 10; diff --git a/src/listeners.ts b/src/listeners.ts index d6b86e00..d93705d8 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -303,22 +303,14 @@ function handle_keydown_event( // Handle fly to next annotation if (event_matches_keybind(keydown_event, ulabel.config.fly_to_next_annotation_keybind)) { - // For 'tab', prevent default - if (keydown_event.key.toLowerCase() === "tab") { - keydown_event.preventDefault(); - } - - if (ulabel.config.fly_to_previous_annotation_keybind === null && shift) { - ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); - } else if (!shift) { - ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom); - } + keydown_event.preventDefault(); + ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom); return false; } // Handle fly to previous annotation - if (ulabel.config.fly_to_previous_annotation_keybind !== null && - event_matches_keybind(keydown_event, ulabel.config.fly_to_previous_annotation_keybind)) { + if (event_matches_keybind(keydown_event, ulabel.config.fly_to_previous_annotation_keybind)) { + keydown_event.preventDefault(); ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom); return false; } diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index d5a9204b..d2911157 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -266,15 +266,13 @@ export class KeybindsToolboxItem extends ToolboxItem { config_key: "fly_to_next_annotation_keybind", }); - if (config.fly_to_previous_annotation_keybind !== null) { - keybinds.push({ - key: config.fly_to_previous_annotation_keybind, - label: "Previous Annotation", - description: "Fly to previous annotation", - configurable: true, - config_key: "fly_to_previous_annotation_keybind", - }); - } + keybinds.push({ + key: config.fly_to_previous_annotation_keybind, + label: "Previous Annotation", + description: "Fly to previous annotation", + configurable: true, + config_key: "fly_to_previous_annotation_keybind", + }); // Resize keybinds from default_keybinds if (config.default_keybinds) { @@ -329,14 +327,6 @@ export class KeybindsToolboxItem extends ToolboxItem { } } - // Non-configurable keybinds (hardcoded in listeners) - keybinds.push({ - key: "Shift+Tab", - label: "Previous Annotation", - description: "Fly to previous annotation (if fly_to_previous_annotation_keybind is null)", - configurable: false, - }); - return keybinds; } From 64e7d4b229a0f666e934daca4767dff34d8c5d6a Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 14:10:58 -0500 Subject: [PATCH 09/43] store collapse state --- .github/tasks.md | 8 +++---- src/toolbox_items/annotation_list.ts | 17 ++++++++++++++ src/toolbox_items/image_filters.ts | 33 +++++++++++++++++++++++++--- src/toolbox_items/keybinds.ts | 26 ++++++++++++++++++---- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 48a83fac..db148d9b 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -23,10 +23,10 @@ - [x] Update key comparison logic in listeners to support chords - [x] Update display to show chords properly (displays captured chord automatically) - [x] Test chord functionality -- [ ] Store collapse/expand for applicable toolbox items - - [ ] Keybinds - - [ ] Annotation List - - [ ] Image Filters +- [x] Store collapse/expand for applicable toolbox items + - [x] Keybinds + - [x] Annotation List + - [x] Image Filters - [ ] Make all keybinds configurable - [ ] Minor changes to existing keybinds - [ ] Rename "Change Zoom" keybind to "Reset Zoom" diff --git a/src/toolbox_items/annotation_list.ts b/src/toolbox_items/annotation_list.ts index 1dc757e7..729baf68 100644 --- a/src/toolbox_items/annotation_list.ts +++ b/src/toolbox_items/annotation_list.ts @@ -2,6 +2,7 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; import { ULabelAnnotation } from "../annotation"; import { ULabelSubtask } from "../subtask"; +import { get_local_storage_item, set_local_storage_item } from "../utilities"; import { BBOX_SVG, DELETE_BBOX_SVG, @@ -264,6 +265,7 @@ export class AnnotationListToolboxItem extends ToolboxItem { // Toggle button to show/hide annotation list $(document).on("click.ulabel", "#annotation-list-toggle", () => { this.is_collapsed = !this.is_collapsed; + set_local_storage_item("ulabel_annotation_list_collapsed", this.is_collapsed ? "true" : "false"); this.update_list(); }); @@ -587,10 +589,25 @@ 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(); + // Initial list update this.update_list(); } + /** + * Restore the collapsed state from localStorage + */ + private restore_collapsed_state(): void { + const stored_state = get_local_storage_item("ulabel_annotation_list_collapsed"); + if (stored_state === "false") { + this.is_collapsed = false; + } else if (stored_state === "true") { + this.is_collapsed = true; + } + } + /** * Update the list when annotations change */ diff --git a/src/toolbox_items/image_filters.ts b/src/toolbox_items/image_filters.ts index 0a481bf8..e53209f8 100644 --- a/src/toolbox_items/image_filters.ts +++ b/src/toolbox_items/image_filters.ts @@ -1,6 +1,7 @@ import type { ULabel } from "../index"; import { SliderHandler } from "../html_builder"; import { ToolboxItem } from "../toolbox"; +import { get_local_storage_item, set_local_storage_item } from "../utilities"; export interface ImageFilterValues { brightness: number; @@ -29,6 +30,7 @@ export class ImageFiltersToolboxItem extends ToolboxItem { private invert_slider: SliderHandler; private saturate_slider: SliderHandler; private ulabel: ULabel; + private is_collapsed: boolean = false; constructor(ulabel: ULabel) { super(); @@ -232,10 +234,34 @@ export class ImageFiltersToolboxItem 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(); + // Apply the initial filter values from config this.apply_filters(); } + /** + * Restore the collapsed state from localStorage + */ + private restore_collapsed_state(): void { + const stored_state = get_local_storage_item("ulabel_image_filters_collapsed"); + if (stored_state === "false") { + this.is_collapsed = false; + } else if (stored_state === "true") { + this.is_collapsed = true; + } + + // Apply the stored state to the UI + const content = document.querySelector("#image-filters-content"); + const toggle_btn = document.querySelector("#image-filters-toggle"); + + if (content && toggle_btn) { + content.style.display = this.is_collapsed ? "none" : "block"; + toggle_btn.innerText = this.is_collapsed ? "▼" : "▲"; + } + } + /** * Initialize event listeners for this toolbox item */ @@ -246,9 +272,10 @@ export class ImageFiltersToolboxItem extends ToolboxItem { const toggle_btn = document.querySelector("#image-filters-toggle"); if (content && toggle_btn) { - const is_hidden = content.style.display === "none"; - content.style.display = is_hidden ? "block" : "none"; - toggle_btn.innerText = is_hidden ? "▲" : "▼"; + this.is_collapsed = !this.is_collapsed; + set_local_storage_item("ulabel_image_filters_collapsed", this.is_collapsed ? "true" : "false"); + content.style.display = this.is_collapsed ? "none" : "block"; + toggle_btn.innerText = this.is_collapsed ? "▼" : "▲"; } }); diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index d2911157..a04798f1 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -1,5 +1,6 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; +import { get_local_storage_item, set_local_storage_item } from "../utilities"; interface KeybindInfo { key: string; @@ -426,8 +427,8 @@ export class KeybindsToolboxItem extends ToolboxItem { return `
-

Keybinds

- + Keybinds +
@@ -450,6 +451,21 @@ export class KeybindsToolboxItem extends ToolboxItem { */ public after_init(): void { this.add_event_listeners(); + this.restore_collapsed_state(); + } + + /** + * Restore the collapsed/expanded state from localStorage + */ + private restore_collapsed_state(): void { + const stored_state = get_local_storage_item("ulabel_keybinds_collapsed"); + if (stored_state === "false") { + // If stored as expanded, expand it + this.is_collapsed = false; + $(".keybinds-content").addClass("expanded"); + $(".keybinds-toggle-btn").text("▲"); + } + // Default is collapsed, so no need to do anything if stored_state is "true" or null } /** @@ -536,10 +552,12 @@ export class KeybindsToolboxItem extends ToolboxItem { if (this.is_collapsed) { content.removeClass("expanded"); - toggle_btn.text("▶"); + toggle_btn.text("▼"); + set_local_storage_item("ulabel_keybinds_collapsed", "true"); } else { content.addClass("expanded"); - toggle_btn.text("▼"); + toggle_btn.text("▲"); + set_local_storage_item("ulabel_keybinds_collapsed", "false"); } }); From b23d1c1f7d01d7ca5b36b9794b9b15112a53d96d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 14:22:01 -0500 Subject: [PATCH 10/43] refactor 'default keybinds' --- .github/tasks.md | 2 +- api_spec.md | 39 +++++++------- src/configuration.ts | 18 ++++--- src/toolbox.ts | 16 +++--- src/toolbox_items/keybinds.ts | 99 ++++++++++++++--------------------- 5 files changed, 77 insertions(+), 97 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index db148d9b..5f9806a9 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -27,7 +27,7 @@ - [x] Keybinds - [x] Annotation List - [x] Image Filters -- [ ] Make all keybinds configurable +- [x] Make all keybinds configurable - [ ] Minor changes to existing keybinds - [ ] Rename "Change Zoom" keybind to "Reset Zoom" - [ ] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" diff --git a/api_spec.md b/api_spec.md index b00e8166..678d3fe6 100644 --- a/api_spec.md +++ b/api_spec.md @@ -50,13 +50,6 @@ class ULabel({ initial_line_size: number, instructions_url: string, toolbox_order: AllowedToolboxItem[], - default_keybinds = { - "annotation_size_small": string, - "annotation_size_large": string, - "annotation_size_plus": string, - "annotation_size_minus": string, - "annotation_vanish": string - }, distance_filter_toolbox_item: FilterDistanceConfig, image_filters_toolbox_item: ImageFiltersConfig, change_zoom_keybind: string, @@ -74,6 +67,11 @@ class ULabel({ decrease_brush_size_keybind: string, fly_to_next_annotation_keybind: string, fly_to_previous_annotation_keybind: string, + annotation_size_small_keybind: string, + annotation_size_large_keybind: string, + annotation_size_plus_keybind: string, + annotation_size_minus_keybind: string, + annotation_vanish_keybind: string, fly_to_max_zoom: number, n_annos_per_canvas: number }) @@ -356,18 +354,6 @@ You can access the AllowedToolboxItem enum by calling the static method: const AllowedToolboxItem = ULabel.get_allowed_toolbox_item_enum(); ``` -### `default_keybinds` -Keybinds can be set to control the annotation session. The default values are: -```javascript -{ - "annotation_size_small": "s", - "annotation_size_large": "l", - "annotation_size_plus": "=", - "annotation_size_minus": "-", - "annotation_vanish": "v" -} -``` - ### `distance_filter_toolbox_item` Configuration object for the `FilterDistance` toolbox item with the following custom definitions: ```javascript @@ -482,6 +468,21 @@ Keybind to set the zoom to focus on the next annotation. Default is `Tab`, which ### `fly_to_previous_annotation_keybind` Keybind to set the zoom to focus on the previous annotation. Default is `shift+tab`. Supports chord keybinds (e.g., `shift+p`, `ctrl+alt+n`). +### `annotation_size_small_keybind` +Keybind to set the annotation size to small for the current subtask. Default is `s`. + +### `annotation_size_large_keybind` +Keybind to set the annotation size to large for the current subtask. Default is `l`. + +### `annotation_size_plus_keybind` +Keybind to increment the annotation size for the current subtask. Default is `=`. + +### `annotation_size_minus_keybind` +Keybind to decrement the annotation size for the current subtask. Default is `-`. + +### `annotation_vanish_keybind` +Keybind to toggle vanish mode for annotations in the current subtask (lowercase toggles current subtask, uppercase toggles all subtasks). Default is `v`. + ### `fly_to_max_zoom` Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`. diff --git a/src/configuration.ts b/src/configuration.ts index 32b4405b..17614601 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -156,14 +156,6 @@ export class Configuration { AllowedToolboxItem.SubmitButtons, ]; - public default_keybinds = { - annotation_size_small: "s", - annotation_size_large: "l", - annotation_size_plus: "=", - annotation_size_minus: "-", - annotation_vanish: "v", - }; - // Config for RecolorActiveItem public recolor_active_toolbox_item: RecolorActiveConfig = { gradient_turned_on: false, @@ -201,6 +193,16 @@ export class Configuration { public decrease_brush_size_keybind: string = "["; + public annotation_size_small_keybind: string = "s"; + + public annotation_size_large_keybind: string = "l"; + + public annotation_size_plus_keybind: string = "="; + + public annotation_size_minus_keybind: string = "-"; + + public annotation_vanish_keybind: string = "v"; + public fly_to_next_annotation_keybind: string = "tab"; public fly_to_previous_annotation_keybind: string = "shift+tab"; diff --git a/src/toolbox.ts b/src/toolbox.ts index 4457f9aa..0361388a 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -1200,7 +1200,6 @@ export class ClassCounterToolboxItem extends ToolboxItem { export class AnnotationResizeItem extends ToolboxItem { public cached_size: number = 1.5; public html: string; - private keybind_configuration: { [key: string]: string }; private ulabel: ULabel; constructor(ulabel: ULabel) { @@ -1208,9 +1207,6 @@ export class AnnotationResizeItem extends ToolboxItem { this.ulabel = ulabel; - // Get default keybinds - this.keybind_configuration = ulabel.config.default_keybinds; - // First check for a size cookie, if one isn't found then check the config // for a default annotation size. If neither are found it will use the size // that the annotation was saved as. @@ -1331,22 +1327,22 @@ export class AnnotationResizeItem extends ToolboxItem { const current_subtask = this.ulabel.get_current_subtask(); switch (event.key) { - case this.keybind_configuration.annotation_vanish.toUpperCase(): + case this.ulabel.config.annotation_vanish_keybind.toUpperCase(): this.update_all_subtask_annotation_size(this.ulabel, ValidResizeValues.VANISH); break; - case this.keybind_configuration.annotation_vanish.toLowerCase(): + case this.ulabel.config.annotation_vanish_keybind.toLowerCase(): this.update_annotation_size(this.ulabel, current_subtask, ValidResizeValues.VANISH); break; - case this.keybind_configuration.annotation_size_small: + case this.ulabel.config.annotation_size_small_keybind: this.update_annotation_size(this.ulabel, current_subtask, ValidResizeValues.SMALL); break; - case this.keybind_configuration.annotation_size_large: + case this.ulabel.config.annotation_size_large_keybind: this.update_annotation_size(this.ulabel, current_subtask, ValidResizeValues.LARGE); break; - case this.keybind_configuration.annotation_size_minus: + case this.ulabel.config.annotation_size_minus_keybind: this.update_annotation_size(this.ulabel, current_subtask, ValidResizeValues.DECREMENT); break; - case this.keybind_configuration.annotation_size_plus: + case this.ulabel.config.annotation_size_plus_keybind: this.update_annotation_size(this.ulabel, current_subtask, ValidResizeValues.INCREMENT); break; default: diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index a04798f1..ed3eb04c 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -275,43 +275,45 @@ export class KeybindsToolboxItem extends ToolboxItem { config_key: "fly_to_previous_annotation_keybind", }); - // Resize keybinds from default_keybinds - if (config.default_keybinds) { - keybinds.push({ - key: config.default_keybinds.annotation_size_small, - label: "Size: Small", - description: "Set annotation size to small", - configurable: false, - }); - - keybinds.push({ - key: config.default_keybinds.annotation_size_large, - label: "Size: Large", - description: "Set annotation size to large", - configurable: false, - }); - - keybinds.push({ - key: config.default_keybinds.annotation_size_plus, - label: "Size: Increase", - description: "Increase annotation size", - configurable: false, - }); - - keybinds.push({ - key: config.default_keybinds.annotation_size_minus, - label: "Size: Decrease", - description: "Decrease annotation size", - configurable: false, - }); - - keybinds.push({ - key: config.default_keybinds.annotation_vanish, - label: "Toggle Vanish", - description: "Toggle annotation vanish mode", - configurable: false, - }); - } + keybinds.push({ + key: config.annotation_size_small_keybind, + label: "Size: Small", + description: "Set annotation size to small", + configurable: true, + config_key: "annotation_size_small_keybind", + }); + + keybinds.push({ + key: config.annotation_size_large_keybind, + label: "Size: Large", + description: "Set annotation size to large", + configurable: true, + config_key: "annotation_size_large_keybind", + }); + + keybinds.push({ + key: config.annotation_size_plus_keybind, + label: "Size: Increase", + description: "Increase annotation size", + configurable: true, + config_key: "annotation_size_plus_keybind", + }); + + keybinds.push({ + key: config.annotation_size_minus_keybind, + label: "Size: Decrease", + description: "Decrease annotation size", + configurable: true, + config_key: "annotation_size_minus_keybind", + }); + + keybinds.push({ + key: config.annotation_vanish_keybind, + label: "Toggle Vanish", + description: "Toggle annotation vanish mode", + configurable: true, + config_key: "annotation_vanish_keybind", + }); // Add class keybinds const current_subtask = this.ulabel.get_current_subtask(); @@ -350,14 +352,9 @@ export class KeybindsToolboxItem extends ToolboxItem { // Group keybinds by category const configurable = all_keybinds.filter((kb) => kb.configurable); const class_binds = all_keybinds.filter((kb) => kb.description.startsWith("Select class:")); - const resize_binds = all_keybinds.filter((kb) => - kb.description.includes("annotation size") || kb.description.includes("vanish"), - ); const other = all_keybinds.filter((kb) => !kb.configurable && - !kb.description.startsWith("Select class:") && - !kb.description.includes("annotation size") && - !kb.description.includes("vanish"), + !kb.description.startsWith("Select class:"), ); // Configurable keybinds @@ -392,22 +389,6 @@ export class KeybindsToolboxItem extends ToolboxItem { } } - // Resize/vanish keybinds - if (resize_binds.length > 0) { - keybinds_html += "
Annotation Display
"; - for (const keybind of resize_binds) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - // Other keybinds if (other.length > 0) { keybinds_html += "
Other
"; From 2e39bd153146d23edcc3785961be688389525df0 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 14:32:09 -0500 Subject: [PATCH 11/43] Rename change zoom -> reset zoom --- .github/tasks.md | 7 ++++--- api_spec.md | 6 +++--- src/configuration.ts | 2 +- src/listeners.ts | 8 ++++---- src/toolbox.ts | 4 ++-- src/toolbox_items/keybinds.ts | 8 ++++---- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 5f9806a9..625a99d8 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -28,8 +28,9 @@ - [x] Annotation List - [x] Image Filters - [x] Make all keybinds configurable -- [ ] Minor changes to existing keybinds - - [ ] Rename "Change Zoom" keybind to "Reset Zoom" - - [ ] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" +- [x] Minor changes to existing keybinds + - [x] Rename "Change Zoom" keybind to "Reset Zoom" + - [x] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" +- [ ] Make class keybinds configurable in the keybinds toolbox item diff --git a/api_spec.md b/api_spec.md index 678d3fe6..e76a0dd9 100644 --- a/api_spec.md +++ b/api_spec.md @@ -52,7 +52,7 @@ class ULabel({ toolbox_order: AllowedToolboxItem[], distance_filter_toolbox_item: FilterDistanceConfig, image_filters_toolbox_item: ImageFiltersConfig, - change_zoom_keybind: string, + reset_zoom_keybind: string, create_point_annotation_keybind: string, default_annotation_size: number, delete_annotation_keybind: string, @@ -423,8 +423,8 @@ The `AnnotationList` toolbox item displays all annotations in the current subtas This toolbox item requires no configuration and can be added to the `toolbox_order` array using `AllowedToolboxItem.AnnotationList`. -### `change_zoom_keybind` -Keybind to change the zoom level. Must be a letter, and the lowercase version of the letter will set the zoom level to the `initial_crop`, while the capitalized version will show the full image. Default is `r`. +### `reset_zoom_keybind` +Keybind to reset the zoom level. Must be a letter, and the lowercase version of the letter will set the zoom level to the `initial_crop`, while the capitalized version will show the full image. Default is `r`. ### `create_point_annotation_keybind` Keybind to create a point annotation at the mouse location. Default is `c`. Requires the active subtask to have a `point` mode. diff --git a/src/configuration.ts b/src/configuration.ts index 17614601..e8535d02 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -167,7 +167,7 @@ export class Configuration { // Config for ImageFiltersToolboxItem public image_filters_toolbox_item: ImageFiltersConfig = DEFAULT_IMAGE_FILTERS_CONFIG; - public change_zoom_keybind: string = "r"; + public reset_zoom_keybind: string = "r"; public create_point_annotation_keybind: string = "c"; diff --git a/src/listeners.ts b/src/listeners.ts index d93705d8..cc4dc9b6 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -132,14 +132,14 @@ function handle_keypress_event( return; } - // Show initial crop (lowercase change zoom keybind) - if (event_matches_keybind(keypress_event, ulabel.config.change_zoom_keybind.toLowerCase())) { + // Show initial crop (lowercase reset zoom keybind) + if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind.toLowerCase())) { ulabel.show_initial_crop(); return; } - // Show whole image (uppercase change zoom keybind) - if (event_matches_keybind(keypress_event, ulabel.config.change_zoom_keybind.toUpperCase())) { + // Show whole image (uppercase reset zoom keybind) + if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind.toUpperCase())) { ulabel.show_whole_image(); return; } diff --git a/src/toolbox.ts b/src/toolbox.ts index 0361388a..ef4d9837 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -945,10 +945,10 @@ export class ZoomPanToolboxItem extends ToolboxItem { }); $(document).on("keypress.ulabel", (e) => { - if (e.key == this.ulabel.config.change_zoom_keybind.toLowerCase()) { + if (e.key == this.ulabel.config.reset_zoom_keybind.toLowerCase()) { document.getElementById("recenter-button").click(); } - if (e.key == this.ulabel.config.change_zoom_keybind.toUpperCase()) { + if (e.key == this.ulabel.config.reset_zoom_keybind.toUpperCase()) { document.getElementById("recenter-whole-image-button").click(); } }); diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index ed3eb04c..38408ddb 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -180,11 +180,11 @@ export class KeybindsToolboxItem extends ToolboxItem { // Configurable keybinds keybinds.push({ - key: config.change_zoom_keybind, - label: "Change Zoom", + key: config.reset_zoom_keybind, + label: "Reset Zoom", description: "Change zoom mode (hold: drag zoom, release: single-click zoom)", configurable: true, - config_key: "change_zoom_keybind", + config_key: "reset_zoom_keybind", }); keybinds.push({ @@ -213,7 +213,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_annotation_mode_keybind, - label: "Toggle Mode", + label: "Toggle Annotation Mode", description: "Toggle between annotation modes", configurable: true, config_key: "toggle_annotation_mode_keybind", From eddd576a531cac16142e9c3c57fc0b8fb2f5e309 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 14:49:07 -0500 Subject: [PATCH 12/43] configurable class keybinds --- .github/tasks.md | 4 +- src/toolbox_items/keybinds.ts | 160 +++++++++++----------------------- 2 files changed, 54 insertions(+), 110 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 625a99d8..6014f08d 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -31,6 +31,8 @@ - [x] Minor changes to existing keybinds - [x] Rename "Change Zoom" keybind to "Reset Zoom" - [x] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" -- [ ] Make class keybinds configurable in the keybinds toolbox item +- [x] Make class keybinds configurable in the keybinds toolbox item +- [ ] Store keybinds in local storage + - [ ] Only save them when a user explicitly sets it diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 38408ddb..ef4280f5 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -8,6 +8,7 @@ interface KeybindInfo { description: string; configurable: boolean; config_key?: string; + class_id?: number; // For class keybinds } /** @@ -324,7 +325,8 @@ export class KeybindsToolboxItem extends ToolboxItem { key: class_def.keybind, label: class_def.name, description: `Select class: ${class_def.name}`, - configurable: false, + configurable: true, + class_id: class_def.id, }); } } @@ -342,20 +344,15 @@ export class KeybindsToolboxItem extends ToolboxItem { } /** - * Generate HTML for the toolbox item + * Generate the keybinds list HTML */ - public get_html(): string { + private generate_keybinds_list_html(): string { const all_keybinds = this.get_all_keybinds(); - let keybinds_html = ""; // Group keybinds by category const configurable = all_keybinds.filter((kb) => kb.configurable); - const class_binds = all_keybinds.filter((kb) => kb.description.startsWith("Select class:")); - const other = all_keybinds.filter((kb) => - !kb.configurable && - !kb.description.startsWith("Select class:"), - ); + const other = all_keybinds.filter((kb) => !kb.configurable); // Configurable keybinds if (configurable.length > 0) { @@ -364,26 +361,16 @@ export class KeybindsToolboxItem extends ToolboxItem { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - // Class keybinds - if (class_binds.length > 0) { - keybinds_html += "
Class Selection
"; - for (const keybind of class_binds) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + // Use class_id for class keybinds, config_key for regular keybinds + const data_attr = keybind.class_id !== undefined ? + `data-class-id="${keybind.class_id}"` : + `data-config-key="${keybind.config_key}"`; + keybinds_html += `
${keybind.label} - ${display_key} + ${display_key}
`; } @@ -405,6 +392,15 @@ export class KeybindsToolboxItem extends ToolboxItem { } } + return keybinds_html; + } + + /** + * Generate HTML for the toolbox item + */ + public get_html(): string { + const keybinds_html = this.generate_keybinds_list_html(); + return `
@@ -547,6 +543,8 @@ export class KeybindsToolboxItem extends ToolboxItem { e.stopPropagation(); const target = $(e.currentTarget); const config_key = target.data("config-key") as string; + const class_id = target.data("class-id") as number; + const is_class_keybind = class_id !== undefined; // If already editing this key, do nothing if (target.hasClass("editing")) { @@ -558,6 +556,7 @@ export class KeybindsToolboxItem extends ToolboxItem { // Add editing class to this key target.addClass("editing"); + const original_value = target.text(); target.text("Press key..."); // Set the editing flag to prevent other key handlers from firing @@ -572,7 +571,7 @@ export class KeybindsToolboxItem extends ToolboxItem { // Handle Escape to cancel if (keyEvent.key === "Escape") { target.removeClass("editing"); - target.text(this.ulabel.config[config_key]); + target.text(original_value); $(document).off("keydown.keybind-edit"); this.ulabel.state.is_editing_keybind = false; return; @@ -586,8 +585,18 @@ export class KeybindsToolboxItem extends ToolboxItem { return; } - // Update the config - this.ulabel.config[config_key] = new_key; + // Update the config or class definition + if (is_class_keybind) { + // Update the class definition keybind + const current_subtask = this.ulabel.get_current_subtask(); + const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); + if (class_def) { + class_def.keybind = new_key; + } + } else { + // Update the config + this.ulabel.config[config_key] = new_key; + } // Update the display target.removeClass("editing"); @@ -611,8 +620,20 @@ export class KeybindsToolboxItem extends ToolboxItem { const editing_key = $(".keybind-key.editing"); if (editing_key.length > 0) { const config_key = editing_key.data("config-key") as string; + const class_id = editing_key.data("class-id") as number; editing_key.removeClass("editing"); - editing_key.text(this.ulabel.config[config_key]); + + // Restore the original value + if (class_id !== undefined) { + const current_subtask = this.ulabel.get_current_subtask(); + const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); + if (class_def) { + editing_key.text(class_def.keybind); + } + } else { + editing_key.text(this.ulabel.config[config_key]); + } + $(document).off("keydown.keybind-edit"); this.ulabel.state.is_editing_keybind = false; } @@ -629,86 +650,7 @@ export class KeybindsToolboxItem extends ToolboxItem { return; } - const all_keybinds = this.get_all_keybinds(); - let keybinds_html = ""; - - // Group keybinds by category - const configurable = all_keybinds.filter((kb) => kb.configurable); - const class_binds = all_keybinds.filter((kb) => kb.description.startsWith("Select class:")); - const resize_binds = all_keybinds.filter((kb) => - kb.description.includes("annotation size") || kb.description.includes("vanish"), - ); - const other = all_keybinds.filter((kb) => - !kb.configurable && - !kb.description.startsWith("Select class:") && - !kb.description.includes("annotation size") && - !kb.description.includes("vanish"), - ); - - // Configurable keybinds - if (configurable.length > 0) { - keybinds_html += "
Configurable Keybinds
"; - for (const keybind of configurable) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - - // Class keybinds - if (class_binds.length > 0) { - keybinds_html += "
Class Selection
"; - for (const keybind of class_binds) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - - // Resize/vanish keybinds - if (resize_binds.length > 0) { - keybinds_html += "
Annotation Display
"; - for (const keybind of resize_binds) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - - // Other keybinds - if (other.length > 0) { - keybinds_html += "
Other
"; - for (const keybind of other) { - const has_collision = this.has_collision(keybind.key, all_keybinds); - const collision_class = has_collision ? " collision" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - keybinds_html += ` -
- ${keybind.label} - ${display_key} -
- `; - } - } - + const keybinds_html = this.generate_keybinds_list_html(); keybinds_list.html(keybinds_html); } } From 2b628d4b4a5319ad7c45fcc875acda6035f579f9 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 14:58:52 -0500 Subject: [PATCH 13/43] store keybinds that user sets --- .github/tasks.md | 4 +- src/initializer.ts | 45 +++++++++++++++++++++ src/toolbox_items/keybinds.ts | 73 +++++++++++++++++++++++++++++++---- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 6014f08d..570ecec0 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -32,7 +32,7 @@ - [x] Rename "Change Zoom" keybind to "Reset Zoom" - [x] Change "Toggle Mode" label in the keybind toolbox item to "Toggle Annotation Mode" - [x] Make class keybinds configurable in the keybinds toolbox item -- [ ] Store keybinds in local storage - - [ ] Only save them when a user explicitly sets it +- [x] Store keybinds in local storage + - [x] Only save them when a user explicitly sets it diff --git a/src/initializer.ts b/src/initializer.ts index 45195799..d985d1b0 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -59,6 +59,48 @@ function make_image_canvases( } } +/** + * Restore custom keybinds from localStorage + * + * @param ulabel ULabel instance to restore keybinds for + */ +function restore_custom_keybinds(ulabel: ULabel) { + // Restore regular keybinds + const stored_keybinds = get_local_storage_item("ulabel_custom_keybinds"); + if (stored_keybinds) { + try { + const custom_keybinds = JSON.parse(stored_keybinds); + for (const [config_key, value] of Object.entries(custom_keybinds)) { + if (config_key in ulabel.config) { + ulabel.config[config_key] = value as string; + } + } + } catch (e) { + console.error("Failed to parse custom keybinds from localStorage:", e); + } + } + + // Restore class keybinds + const stored_class_keybinds = get_local_storage_item("ulabel_custom_class_keybinds"); + if (stored_class_keybinds) { + try { + const custom_class_keybinds = JSON.parse(stored_class_keybinds); + for (const subtask_key in ulabel.subtasks) { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask.class_defs) { + for (const class_def of subtask.class_defs) { + if (class_def.id in custom_class_keybinds) { + class_def.keybind = custom_class_keybinds[class_def.id]; + } + } + } + } + } catch (e) { + console.error("Failed to parse custom class keybinds from localStorage:", e); + } + } +} + /** * ULabel initializer logic. * Async to ensure correct processing order; many steps are dependent on knowing the image/canvas size. @@ -73,6 +115,9 @@ export async function ulabel_init( // Add stylesheet add_style_to_document(ulabel); + // Restore custom keybinds from localStorage + restore_custom_keybinds(ulabel); + // Set current subtask to first subtask ulabel.state["current_subtask"] = Object.keys(ulabel.subtasks)[0]; diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index ef4280f5..2641970e 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -332,6 +332,28 @@ export class KeybindsToolboxItem extends ToolboxItem { } } + // Non-configurable keybinds + keybinds.push({ + key: "ctrl+z", + label: "Undo", + description: "Undo the last action", + configurable: false, + }); + + keybinds.push({ + key: "ctrl+shift+z", + label: "Redo", + description: "Redo the last undone action", + configurable: false, + }); + + keybinds.push({ + key: "Escape", + label: "Cancel", + description: "Cancel current action, exit brush/erase mode, or cancel annotation in progress", + configurable: false, + }); + return keybinds; } @@ -343,6 +365,26 @@ export class KeybindsToolboxItem extends ToolboxItem { return occurrences > 1; } + /** + * Save a regular keybind to localStorage + */ + private save_keybind_to_storage(config_key: string, value: string): void { + const stored = get_local_storage_item("ulabel_custom_keybinds"); + const custom_keybinds = stored ? JSON.parse(stored) : {}; + custom_keybinds[config_key] = value; + set_local_storage_item("ulabel_custom_keybinds", JSON.stringify(custom_keybinds)); + } + + /** + * Save a class keybind to localStorage + */ + private save_class_keybind_to_storage(class_id: number, value: string): void { + const stored = get_local_storage_item("ulabel_custom_class_keybinds"); + const custom_class_keybinds = stored ? JSON.parse(stored) : {}; + custom_class_keybinds[class_id] = value; + set_local_storage_item("ulabel_custom_class_keybinds", JSON.stringify(custom_class_keybinds)); + } + /** * Generate the keybinds list HTML */ @@ -351,10 +393,11 @@ export class KeybindsToolboxItem extends ToolboxItem { let keybinds_html = ""; // Group keybinds by category - const configurable = all_keybinds.filter((kb) => kb.configurable); + const configurable = all_keybinds.filter((kb) => kb.configurable && kb.class_id === undefined); + const class_keybinds = all_keybinds.filter((kb) => kb.class_id !== undefined); const other = all_keybinds.filter((kb) => !kb.configurable); - // Configurable keybinds + // Configurable keybinds (non-class) if (configurable.length > 0) { keybinds_html += "
Configurable Keybinds
"; for (const keybind of configurable) { @@ -362,15 +405,27 @@ export class KeybindsToolboxItem extends ToolboxItem { const collision_class = has_collision ? " collision" : ""; const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - // Use class_id for class keybinds, config_key for regular keybinds - const data_attr = keybind.class_id !== undefined ? - `data-class-id="${keybind.class_id}"` : - `data-config-key="${keybind.config_key}"`; + keybinds_html += ` +
+ ${keybind.label} + ${display_key} +
+ `; + } + } + + // Class keybinds + if (class_keybinds.length > 0) { + keybinds_html += "
Class Keybinds
"; + for (const keybind of class_keybinds) { + const has_collision = this.has_collision(keybind.key, all_keybinds); + const collision_class = has_collision ? " collision" : ""; + const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; keybinds_html += `
${keybind.label} - ${display_key} + ${display_key}
`; } @@ -592,10 +647,14 @@ export class KeybindsToolboxItem extends ToolboxItem { const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); if (class_def) { class_def.keybind = new_key; + // Save class keybind to localStorage + this.save_class_keybind_to_storage(class_id, new_key); } } else { // Update the config this.ulabel.config[config_key] = new_key; + // Save regular keybind to localStorage + this.save_keybind_to_storage(config_key, new_key); } // Update the display From 660897a4991cda703b3f97be73a5bdf04d5c42b9 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 15:02:27 -0500 Subject: [PATCH 14/43] add collapse to keybind sections --- src/configuration.ts | 1 + src/toolbox_items/keybinds.ts | 80 +++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index e8535d02..da2a29f2 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -142,6 +142,7 @@ export class Configuration { // Default toolbox order used when the user doesn't specify one public toolbox_order: AllowedToolboxItem[] = [ + AllowedToolboxItem.Keybinds, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, AllowedToolboxItem.Keybinds, diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 2641970e..9c3d1a01 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -157,6 +157,30 @@ export class KeybindsToolboxItem extends ToolboxItem { margin-bottom: 0.5rem; padding: 0.25rem 0.5rem; color: rgba(0, 128, 255, 0.9); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; + } + + #toolbox .keybind-category:hover { + background-color: rgba(0, 128, 255, 0.05); + } + + #toolbox .keybind-category-toggle { + font-size: 0.8rem; + margin-left: 0.5rem; + } + + #toolbox .keybind-section-items { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + #toolbox .keybind-section-items.collapsed { + display: none; } `; @@ -399,7 +423,13 @@ export class KeybindsToolboxItem extends ToolboxItem { // Configurable keybinds (non-class) if (configurable.length > 0) { - keybinds_html += "
Configurable Keybinds
"; + keybinds_html += ` +
+ Configurable Keybinds + +
+
+ `; for (const keybind of configurable) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; @@ -412,11 +442,18 @@ export class KeybindsToolboxItem extends ToolboxItem {
`; } + keybinds_html += "
"; } // Class keybinds if (class_keybinds.length > 0) { - keybinds_html += "
Class Keybinds
"; + keybinds_html += ` +
+ Class Keybinds + +
+
+ `; for (const keybind of class_keybinds) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; @@ -429,11 +466,18 @@ export class KeybindsToolboxItem extends ToolboxItem {
`; } + keybinds_html += "
"; } // Other keybinds if (other.length > 0) { - keybinds_html += "
Other
"; + keybinds_html += ` +
+ Other + +
+
+ `; for (const keybind of other) { const has_collision = this.has_collision(keybind.key, all_keybinds); const collision_class = has_collision ? " collision" : ""; @@ -445,6 +489,7 @@ export class KeybindsToolboxItem extends ToolboxItem {
`; } + keybinds_html += "
"; } return keybinds_html; @@ -498,6 +543,16 @@ export class KeybindsToolboxItem extends ToolboxItem { $(".keybinds-toggle-btn").text("▲"); } // Default is collapsed, so no need to do anything if stored_state is "true" or null + + // Restore category section states + const sections = ["configurable", "class", "other"]; + for (const section of sections) { + const section_state = get_local_storage_item(`ulabel_keybind_section_${section}_collapsed`); + if (section_state === "true") { + $(`.keybind-section-items[data-section="${section}"]`).addClass("collapsed"); + $(`.keybind-category[data-section="${section}"] .keybind-category-toggle`).text("▶"); + } + } } /** @@ -593,6 +648,25 @@ export class KeybindsToolboxItem extends ToolboxItem { } }); + // Toggle collapse/expand for category sections + $(document).on("click.ulabel", ".keybind-category", (e) => { + e.stopPropagation(); + const category = $(e.currentTarget); + const section = category.data("section"); + const items = $(`.keybind-section-items[data-section="${section}"]`); + const toggle = category.find(".keybind-category-toggle"); + + if (items.hasClass("collapsed")) { + items.removeClass("collapsed"); + toggle.text("▼"); + set_local_storage_item(`ulabel_keybind_section_${section}_collapsed`, "false"); + } else { + items.addClass("collapsed"); + toggle.text("▶"); + set_local_storage_item(`ulabel_keybind_section_${section}_collapsed`, "true"); + } + }); + // Edit functionality for configurable keybinds $(document).on("click.ulabel", ".keybind-key.keybind-editable", (e) => { e.stopPropagation(); From ffff4270a99168b18a9cc85efef13641f02a8151 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 15:13:50 -0500 Subject: [PATCH 15/43] Reset to default --- .github/tasks.md | 5 +- src/toolbox_items/keybinds.ts | 265 +++++++++++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 3 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 570ecec0..5ed01a4d 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -34,5 +34,8 @@ - [x] Make class keybinds configurable in the keybinds toolbox item - [x] Store keybinds in local storage - [x] Only save them when a user explicitly sets it - + - [x] For keybinds using a user setting, add a button to reset that keybind to default (should change keybind and delete stored keybind) + - [x] Add "Reset All to Default" button in the keybinds toolbox item that resets all keybinds and deletes stored user keybinds + - [ ] Add a light yellow highlight on keybinds that are using a user setting instead of a default + - [ ] Make sure that we update collison highlights after resetting a keybind to default diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 9c3d1a01..20f28e14 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -1,6 +1,7 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; import { get_local_storage_item, set_local_storage_item } from "../utilities"; +import { Configuration } from "../configuration"; interface KeybindInfo { key: string; @@ -9,6 +10,7 @@ interface KeybindInfo { configurable: boolean; config_key?: string; class_id?: number; // For class keybinds + default_key?: string; // Default value for the keybind } /** @@ -74,6 +76,38 @@ export class KeybindsToolboxItem extends ToolboxItem { display: block; } + #toolbox .keybinds-reset-all { + padding: 0.5rem; + margin-bottom: 0.5rem; + } + + #toolbox .keybinds-reset-all-btn { + width: 100%; + padding: 0.5rem; + background-color: rgba(255, 0, 0, 0.1); + border: 1px solid rgba(255, 0, 0, 0.3); + color: #d00; + cursor: pointer; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 500; + } + + #toolbox .keybinds-reset-all-btn:hover { + background-color: rgba(255, 0, 0, 0.2); + border-color: rgba(255, 0, 0, 0.5); + } + + .ulabel-night #toolbox .keybinds-reset-all-btn { + color: #f66; + border-color: rgba(255, 102, 102, 0.3); + } + + .ulabel-night #toolbox .keybinds-reset-all-btn:hover { + background-color: rgba(255, 102, 102, 0.2); + border-color: rgba(255, 102, 102, 0.5); + } + #toolbox .keybinds-list { display: flex; flex-direction: column; @@ -110,6 +144,41 @@ export class KeybindsToolboxItem extends ToolboxItem { white-space: nowrap; } + #toolbox .keybind-controls { + display: flex; + align-items: center; + gap: 0.25rem; + } + + #toolbox .keybind-reset-btn { + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 0.2rem 0.4rem; + font-size: 0.75rem; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s; + } + + #toolbox .keybind-item:hover .keybind-reset-btn { + opacity: 1; + } + + #toolbox .keybind-reset-btn:hover { + background-color: rgba(255, 0, 0, 0.1); + color: #d00; + } + + .ulabel-night #toolbox .keybind-reset-btn { + color: #aaa; + } + + .ulabel-night #toolbox .keybind-reset-btn:hover { + color: #f66; + } + .ulabel-night #toolbox .keybind-description { color: #ddd; } @@ -343,6 +412,9 @@ export class KeybindsToolboxItem extends ToolboxItem { // Add class keybinds const current_subtask = this.ulabel.get_current_subtask(); if (current_subtask && current_subtask.class_defs) { + // Get original keybinds from subtask config before any customization + const original_class_keybinds = this.get_original_class_keybinds(); + for (const class_def of current_subtask.class_defs) { if (class_def.keybind !== null) { keybinds.push({ @@ -351,6 +423,7 @@ export class KeybindsToolboxItem extends ToolboxItem { description: `Select class: ${class_def.name}`, configurable: true, class_id: class_def.id, + default_key: original_class_keybinds[class_def.id] || class_def.keybind, }); } } @@ -409,6 +482,153 @@ export class KeybindsToolboxItem extends ToolboxItem { set_local_storage_item("ulabel_custom_class_keybinds", JSON.stringify(custom_class_keybinds)); } + /** + * Get the default value for a keybind + */ + private get_default_keybind(config_key: string): string { + // Create a new configuration instance to get defaults + const default_config = new Configuration(); + return default_config[config_key] as string; + } + + /** + * Reset a keybind to its default value + */ + private reset_keybind_to_default(config_key: string): void { + const default_value = this.get_default_keybind(config_key); + this.ulabel.config[config_key] = default_value; + + // Remove from localStorage + const stored = get_local_storage_item("ulabel_custom_keybinds"); + if (stored) { + try { + const custom_keybinds = JSON.parse(stored); + delete custom_keybinds[config_key]; + if (Object.keys(custom_keybinds).length > 0) { + set_local_storage_item("ulabel_custom_keybinds", JSON.stringify(custom_keybinds)); + } else { + // Remove the key entirely if empty + localStorage.removeItem("ulabel_custom_keybinds"); + } + } catch (e) { + console.error("Failed to update custom keybinds:", e); + } + } + } + + /** + * Get original class keybinds (before customization) + */ + private get_original_class_keybinds(): { [class_id: number]: string } { + const stored = get_local_storage_item("ulabel_original_class_keybinds"); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + console.error("Failed to parse original class keybinds:", e); + return {}; + } + } + return {}; + } + + /** + * Store original class keybinds (before customization) + */ + private store_original_class_keybinds(): void { + // Only store if not already stored + const stored = get_local_storage_item("ulabel_original_class_keybinds"); + if (stored) { + return; // Already stored + } + + const current_subtask = this.ulabel.get_current_subtask(); + if (current_subtask && current_subtask.class_defs) { + const original_keybinds: { [class_id: number]: string } = {}; + for (const class_def of current_subtask.class_defs) { + if (class_def.keybind !== null) { + original_keybinds[class_def.id] = class_def.keybind; + } + } + set_local_storage_item("ulabel_original_class_keybinds", JSON.stringify(original_keybinds)); + } + } + + /** + * Reset a class keybind to its default value + */ + private reset_class_keybind_to_default(class_id: number, original_keybind: string): void { + const current_subtask = this.ulabel.get_current_subtask(); + const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); + if (class_def) { + class_def.keybind = original_keybind; + } + + // Remove from localStorage + const stored = get_local_storage_item("ulabel_custom_class_keybinds"); + if (stored) { + try { + const custom_class_keybinds = JSON.parse(stored); + delete custom_class_keybinds[class_id]; + if (Object.keys(custom_class_keybinds).length > 0) { + set_local_storage_item("ulabel_custom_class_keybinds", JSON.stringify(custom_class_keybinds)); + } else { + // Remove the key entirely if empty + localStorage.removeItem("ulabel_custom_class_keybinds"); + } + } catch (e) { + console.error("Failed to update custom class keybinds:", e); + } + } + } + + /** + * Reset all keybinds to their default values + */ + private reset_all_keybinds_to_default(): void { + // Get all keybind config keys + const keybind_keys = [ + "reset_zoom_keybind", + "create_point_annotation_keybind", + "delete_annotation_keybind", + "switch_subtask_keybind", + "toggle_annotation_mode_keybind", + "create_bbox_on_initial_crop", + "toggle_brush_mode_keybind", + "toggle_erase_mode_keybind", + "increase_brush_size_keybind", + "decrease_brush_size_keybind", + "fly_to_next_annotation_keybind", + "fly_to_previous_annotation_keybind", + "annotation_size_small_keybind", + "annotation_size_large_keybind", + "annotation_size_plus_keybind", + "annotation_size_minus_keybind", + "annotation_vanish_keybind", + ]; + + // Reset all regular keybinds + for (const key of keybind_keys) { + const default_value = this.get_default_keybind(key); + this.ulabel.config[key] = default_value; + } + + // Reset all class keybinds + const original_class_keybinds = this.get_original_class_keybinds(); + const current_subtask = this.ulabel.get_current_subtask(); + if (current_subtask && current_subtask.class_defs) { + for (const class_def of current_subtask.class_defs) { + if (class_def.id in original_class_keybinds) { + class_def.keybind = original_class_keybinds[class_def.id]; + } + } + } + + // Clear localStorage + localStorage.removeItem("ulabel_custom_keybinds"); + localStorage.removeItem("ulabel_custom_class_keybinds"); + } + /** * Generate the keybinds list HTML */ @@ -438,7 +658,10 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds_html += `
${keybind.label} - ${display_key} +
+ ${display_key} + +
`; } @@ -462,7 +685,10 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds_html += `
${keybind.label} - ${display_key} +
+ ${display_key} + +
`; } @@ -508,6 +734,9 @@ export class KeybindsToolboxItem extends ToolboxItem {
+
+ +
${keybinds_html}
@@ -527,6 +756,7 @@ export class KeybindsToolboxItem extends ToolboxItem { * Called after ULabel initialization is complete */ public after_init(): void { + this.store_original_class_keybinds(); this.add_event_listeners(); this.restore_collapsed_state(); } @@ -667,6 +897,37 @@ export class KeybindsToolboxItem extends ToolboxItem { } }); + // Reset individual keybind to default + $(document).on("click.ulabel", ".keybind-reset-btn", (e) => { + e.stopPropagation(); + const button = $(e.currentTarget); + const config_key = button.data("config-key") as string; + const class_id = button.data("class-id") as number; + const original_keybind = button.data("original-keybind") as string; + + if (class_id !== undefined) { + // Reset class keybind + this.reset_class_keybind_to_default(class_id, original_keybind); + } else if (config_key) { + // Reset regular keybind + this.reset_keybind_to_default(config_key); + } + + // Refresh the display + this.refresh_keybinds_display(); + }); + + // Reset all keybinds to default + $(document).on("click.ulabel", ".keybinds-reset-all-btn", (e) => { + e.stopPropagation(); + + // Confirm with user + if (confirm("Reset all keybinds to their default values?")) { + this.reset_all_keybinds_to_default(); + this.refresh_keybinds_display(); + } + }); + // Edit functionality for configurable keybinds $(document).on("click.ulabel", ".keybind-key.keybind-editable", (e) => { e.stopPropagation(); From 7a19c72a4b3f4e21b48bf56f7597987b4a9ac8d9 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 15:26:53 -0500 Subject: [PATCH 16/43] dont use local storage for og keybind check --- .github/tasks.md | 6 ++- src/initializer.ts | 25 ++++++++++++ src/toolbox_items/keybinds.ts | 71 ++++++++++++++++++++++------------- 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 5ed01a4d..13573ec5 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -36,6 +36,8 @@ - [x] Only save them when a user explicitly sets it - [x] For keybinds using a user setting, add a button to reset that keybind to default (should change keybind and delete stored keybind) - [x] Add "Reset All to Default" button in the keybinds toolbox item that resets all keybinds and deletes stored user keybinds - - [ ] Add a light yellow highlight on keybinds that are using a user setting instead of a default - - [ ] Make sure that we update collison highlights after resetting a keybind to default + - [x] Add a light yellow highlight on keybinds that are using a user setting instead of a default + - [x] Make sure that we update collison highlights after resetting a keybind to default + - [x] Only show the reset to default for keybinds with user settings, not on those already at the default + - [ ] Make sure the class keybinds also are included in the keybind collision checks diff --git a/src/initializer.ts b/src/initializer.ts index d985d1b0..be5f200c 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -59,12 +59,37 @@ function make_image_canvases( } } +/** + * Store original class keybinds before customization + * + * @param ulabel ULabel instance to store original keybinds for + */ +function store_original_class_keybinds(ulabel: ULabel) { + // Store original class keybinds in the ULabel state for later reference + const original_keybinds: { [class_id: number]: string } = {}; + for (const subtask_key in ulabel.subtasks) { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask.class_defs) { + for (const class_def of subtask.class_defs) { + if (class_def.keybind !== null) { + original_keybinds[class_def.id] = class_def.keybind; + } + } + } + } + // Store in ULabel state for toolbox access + ulabel.state["original_class_keybinds"] = original_keybinds; +} + /** * Restore custom keybinds from localStorage * * @param ulabel ULabel instance to restore keybinds for */ function restore_custom_keybinds(ulabel: ULabel) { + // First, store the original class keybinds before applying customizations + store_original_class_keybinds(ulabel); + // Restore regular keybinds const stored_keybinds = get_local_storage_item("ulabel_custom_keybinds"); if (stored_keybinds) { diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 20f28e14..8f25118c 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -158,7 +158,7 @@ export class KeybindsToolboxItem extends ToolboxItem { padding: 0.2rem 0.4rem; font-size: 0.75rem; border-radius: 3px; - opacity: 0; + opacity: 0.5; transition: opacity 0.2s; } @@ -200,6 +200,16 @@ export class KeybindsToolboxItem extends ToolboxItem { background-color: rgba(255, 255, 255, 0.1); } + #toolbox .keybind-key.customized { + background-color: rgba(255, 255, 0, 0.2); + border: 1px solid rgba(255, 200, 0, 0.5); + } + + .ulabel-night #toolbox .keybind-key.customized { + background-color: rgba(255, 255, 0, 0.15); + border-color: rgba(255, 200, 0, 0.4); + } + #toolbox .keybind-key.collision { background-color: rgba(255, 0, 0, 0.3); border: 1px solid red; @@ -520,38 +530,40 @@ export class KeybindsToolboxItem extends ToolboxItem { * Get original class keybinds (before customization) */ private get_original_class_keybinds(): { [class_id: number]: string } { - const stored = get_local_storage_item("ulabel_original_class_keybinds"); + // Get from ULabel state (stored during initialization) + return this.ulabel.state["original_class_keybinds"] || {}; + } + + /** + * Check if a regular keybind is customized (different from default) + */ + private is_keybind_customized(config_key: string): boolean { + const stored = get_local_storage_item("ulabel_custom_keybinds"); if (stored) { try { - return JSON.parse(stored); - } catch (e) { - console.error("Failed to parse original class keybinds:", e); - return {}; + const custom_keybinds = JSON.parse(stored); + return config_key in custom_keybinds; + } catch { + return false; } } - return {}; + return false; } /** - * Store original class keybinds (before customization) + * Check if a class keybind is customized (different from default) */ - private store_original_class_keybinds(): void { - // Only store if not already stored - const stored = get_local_storage_item("ulabel_original_class_keybinds"); + private is_class_keybind_customized(class_id: number): boolean { + const stored = get_local_storage_item("ulabel_custom_class_keybinds"); if (stored) { - return; // Already stored - } - - const current_subtask = this.ulabel.get_current_subtask(); - if (current_subtask && current_subtask.class_defs) { - const original_keybinds: { [class_id: number]: string } = {}; - for (const class_def of current_subtask.class_defs) { - if (class_def.keybind !== null) { - original_keybinds[class_def.id] = class_def.keybind; - } + try { + const custom_class_keybinds = JSON.parse(stored); + return class_id in custom_class_keybinds; + } catch { + return false; } - set_local_storage_item("ulabel_original_class_keybinds", JSON.stringify(original_keybinds)); } + return false; } /** @@ -652,15 +664,18 @@ export class KeybindsToolboxItem extends ToolboxItem { `; for (const keybind of configurable) { const has_collision = this.has_collision(keybind.key, all_keybinds); + const is_customized = this.is_keybind_customized(keybind.config_key); const collision_class = has_collision ? " collision" : ""; + const customized_class = is_customized ? " customized" : ""; const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + const reset_button = is_customized ? `` : ""; keybinds_html += `
${keybind.label}
- ${display_key} - + ${display_key} + ${reset_button}
`; @@ -679,15 +694,18 @@ export class KeybindsToolboxItem extends ToolboxItem { `; for (const keybind of class_keybinds) { const has_collision = this.has_collision(keybind.key, all_keybinds); + const is_customized = this.is_class_keybind_customized(keybind.class_id); const collision_class = has_collision ? " collision" : ""; + const customized_class = is_customized ? " customized" : ""; const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; + const reset_button = is_customized ? `` : ""; keybinds_html += `
${keybind.label}
- ${display_key} - + ${display_key} + ${reset_button}
`; @@ -756,7 +774,6 @@ export class KeybindsToolboxItem extends ToolboxItem { * Called after ULabel initialization is complete */ public after_init(): void { - this.store_original_class_keybinds(); this.add_event_listeners(); this.restore_collapsed_state(); } From e883802cd131db1d45285eaafd8ed8c606f0227d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 15:42:02 -0500 Subject: [PATCH 17/43] fix original keybinds to use config options passed to constructor --- .github/tasks.md | 3 ++- src/initializer.ts | 44 ++++++++++++++++++++++++++++------- src/toolbox_items/keybinds.ts | 25 +++++++++++++++----- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 13573ec5..82de8886 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -39,5 +39,6 @@ - [x] Add a light yellow highlight on keybinds that are using a user setting instead of a default - [x] Make sure that we update collison highlights after resetting a keybind to default - [x] Only show the reset to default for keybinds with user settings, not on those already at the default - - [ ] Make sure the class keybinds also are included in the keybind collision checks + - [x] Make sure the class keybinds also are included in the keybind collision checks + - [x] Fix reset to default to use constructor-provided config values instead of hardcoded Configuration defaults diff --git a/src/initializer.ts b/src/initializer.ts index be5f200c..b41e22a6 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -60,25 +60,53 @@ function make_image_canvases( } /** - * Store original class keybinds before customization + * Store original keybinds before customization * * @param ulabel ULabel instance to store original keybinds for */ -function store_original_class_keybinds(ulabel: ULabel) { +function store_original_keybinds(ulabel: ULabel) { + // Store original config keybinds (from constructor, before localStorage) + const original_config_keybinds: { [config_key: string]: string } = {}; + const keybind_keys = [ + "reset_zoom_keybind", + "create_point_annotation_keybind", + "delete_annotation_keybind", + "switch_subtask_keybind", + "toggle_annotation_mode_keybind", + "create_bbox_on_initial_crop", + "toggle_brush_mode_keybind", + "toggle_erase_mode_keybind", + "increase_brush_size_keybind", + "decrease_brush_size_keybind", + "fly_to_next_annotation_keybind", + "fly_to_previous_annotation_keybind", + "annotation_size_small_keybind", + "annotation_size_large_keybind", + "annotation_size_plus_keybind", + "annotation_size_minus_keybind", + "annotation_vanish_keybind", + ]; + + for (const key of keybind_keys) { + if (key in ulabel.config) { + original_config_keybinds[key] = ulabel.config[key] as string; + } + } + ulabel.state["original_config_keybinds"] = original_config_keybinds; + // Store original class keybinds in the ULabel state for later reference - const original_keybinds: { [class_id: number]: string } = {}; + const original_class_keybinds: { [class_id: number]: string } = {}; for (const subtask_key in ulabel.subtasks) { const subtask = ulabel.subtasks[subtask_key]; if (subtask.class_defs) { for (const class_def of subtask.class_defs) { if (class_def.keybind !== null) { - original_keybinds[class_def.id] = class_def.keybind; + original_class_keybinds[class_def.id] = class_def.keybind; } } } } - // Store in ULabel state for toolbox access - ulabel.state["original_class_keybinds"] = original_keybinds; + ulabel.state["original_class_keybinds"] = original_class_keybinds; } /** @@ -87,8 +115,8 @@ function store_original_class_keybinds(ulabel: ULabel) { * @param ulabel ULabel instance to restore keybinds for */ function restore_custom_keybinds(ulabel: ULabel) { - // First, store the original class keybinds before applying customizations - store_original_class_keybinds(ulabel); + // First, store the original keybinds before applying customizations + store_original_keybinds(ulabel); // Restore regular keybinds const stored_keybinds = get_local_storage_item("ulabel_custom_keybinds"); diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 8f25118c..b354d624 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -1,7 +1,6 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; import { get_local_storage_item, set_local_storage_item } from "../utilities"; -import { Configuration } from "../configuration"; interface KeybindInfo { key: string; @@ -468,7 +467,18 @@ export class KeybindsToolboxItem extends ToolboxItem { * Check if a key has collisions with other keybinds */ private has_collision(key: string, all_keybinds: KeybindInfo[]): boolean { - const occurrences = all_keybinds.filter((kb) => kb.key === key).length; + // Skip null/undefined keys + if (!key) return false; + + // Normalize key for comparison (case-insensitive) + const normalized_key = String(key).toLowerCase(); + + const occurrences = all_keybinds.filter((kb) => { + // Skip null/undefined keybinds + if (!kb.key) return false; + const kb_normalized = String(kb.key).toLowerCase(); + return kb_normalized === normalized_key; + }).length; return occurrences > 1; } @@ -493,12 +503,15 @@ export class KeybindsToolboxItem extends ToolboxItem { } /** - * Get the default value for a keybind + * Get the default value for a keybind (from constructor, not hardcoded defaults) */ private get_default_keybind(config_key: string): string { - // Create a new configuration instance to get defaults - const default_config = new Configuration(); - return default_config[config_key] as string; + const original_config_keybinds = this.ulabel.state["original_config_keybinds"]; + if (original_config_keybinds && config_key in original_config_keybinds) { + return original_config_keybinds[config_key]; + } + // Fallback to current config value if not found in original keybinds + return this.ulabel.config[config_key] as string; } /** From f4c2494b15a8f01a85b50608f05ee4dbe217c049 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 15:57:31 -0500 Subject: [PATCH 18/43] centralize keybind config names --- .github/tasks.md | 3 +++ api_spec.md | 4 ++-- demo/multi-class.html | 2 +- src/configuration.ts | 25 ++++++++++++++++++++++++- src/initializer.ts | 24 +++--------------------- src/listeners.ts | 2 +- src/toolbox_items/keybinds.ts | 28 ++++------------------------ 7 files changed, 38 insertions(+), 50 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 82de8886..2d030d3b 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -41,4 +41,7 @@ - [x] Only show the reset to default for keybinds with user settings, not on those already at the default - [x] Make sure the class keybinds also are included in the keybind collision checks - [x] Fix reset to default to use constructor-provided config values instead of hardcoded Configuration defaults + - [x] Centralize keybind config property names in Configuration.KEYBIND_CONFIG_KEYS constant + - [x] Make KEYBIND_CONFIG_KEYS dynamically generated from Configuration class properties + - [x] Rename create_bbox_on_initial_crop to create_bbox_on_initial_crop_keybind for consistency diff --git a/api_spec.md b/api_spec.md index e76a0dd9..5b98bb26 100644 --- a/api_spec.md +++ b/api_spec.md @@ -60,7 +60,7 @@ class ULabel({ filter_annotations_on_load: boolean, switch_subtask_keybind: string, toggle_annotation_mode_keybind: string, - create_bbox_on_initial_crop: string, + create_bbox_on_initial_crop_keybind: string, toggle_brush_mode_keybind: string, toggle_erase_mode_keybind: string, increase_brush_size_keybind: string, @@ -447,7 +447,7 @@ Keybind to switch between subtasks. Default is `z`. ### `toggle_annotation_mode_keybind` Keybind to toggle between annotation and selection modes. Default is `u`. -### `create_bbox_on_initial_crop` +### `create_bbox_on_initial_crop_keybind` Keybind to create a bounding box annotation around the `initial_crop`. Default is `f`. Requires the active subtask to have a `bbox` mode. ### `toggle_brush_mode_keybind` diff --git a/demo/multi-class.html b/demo/multi-class.html index ac9d965b..cad42002 100644 --- a/demo/multi-class.html +++ b/demo/multi-class.html @@ -132,7 +132,7 @@ AllowedToolboxItem.RecolorActive, ], "toggle_brush_mode_keybind": "f", - "create_bbox_on_initial_crop": "|", + "create_bbox_on_initial_crop_keybind": "|", "click_and_drag_poly_annotations": false, "anno_scaling_mode": "fixed", "allow_annotations_outside_image": false, diff --git a/src/configuration.ts b/src/configuration.ts index da2a29f2..b61a31be 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -72,6 +72,29 @@ export const DEFAULT_IMAGE_FILTERS_CONFIG: ImageFiltersConfig = { }; export class Configuration { + /** + * Dynamically get all keybind configuration property names. + * Scans the Configuration class for properties ending in "_keybind" or known keybind properties. + * Use this to iterate over keybind properties without maintaining a hardcoded list. + */ + public static get KEYBIND_CONFIG_KEYS(): readonly string[] { + // Get all properties from a Configuration instance + const config = new Configuration(); + const keybind_keys: string[] = []; + + for (const key in config) { + // Include all properties ending with "_keybind" + if (key.endsWith("_keybind")) { + // Verify it's a string property (keybinds should be strings) + if (typeof config[key] === "string") { + keybind_keys.push(key); + } + } + } + + return keybind_keys; + } + // Values useful for generating HTML for tool public container_id: string = "container"; public px_per_px: number = 1; @@ -184,7 +207,7 @@ export class Configuration { public toggle_annotation_mode_keybind: string = "u"; - public create_bbox_on_initial_crop: string = "f"; + public create_bbox_on_initial_crop_keybind: string = "f"; public toggle_brush_mode_keybind: string = "g"; diff --git a/src/initializer.ts b/src/initializer.ts index b41e22a6..ed885e6b 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -7,6 +7,7 @@ // Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; import { initialize_annotation_canvases } from "./canvas_utils"; +import { Configuration } from "./configuration"; import { NightModeCookie } from "./cookies"; import { add_style_to_document, build_confidence_dialog, build_edit_suggestion, build_id_dialogs, prep_window_html } from "./html_builder"; import { create_ulabel_listeners } from "./listeners"; @@ -67,27 +68,8 @@ function make_image_canvases( function store_original_keybinds(ulabel: ULabel) { // Store original config keybinds (from constructor, before localStorage) const original_config_keybinds: { [config_key: string]: string } = {}; - const keybind_keys = [ - "reset_zoom_keybind", - "create_point_annotation_keybind", - "delete_annotation_keybind", - "switch_subtask_keybind", - "toggle_annotation_mode_keybind", - "create_bbox_on_initial_crop", - "toggle_brush_mode_keybind", - "toggle_erase_mode_keybind", - "increase_brush_size_keybind", - "decrease_brush_size_keybind", - "fly_to_next_annotation_keybind", - "fly_to_previous_annotation_keybind", - "annotation_size_small_keybind", - "annotation_size_large_keybind", - "annotation_size_plus_keybind", - "annotation_size_minus_keybind", - "annotation_vanish_keybind", - ]; - - for (const key of keybind_keys) { + + for (const key of Configuration.KEYBIND_CONFIG_KEYS) { if (key in ulabel.config) { original_config_keybinds[key] = ulabel.config[key] as string; } diff --git a/src/listeners.ts b/src/listeners.ts index cc4dc9b6..33c88de3 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -78,7 +78,7 @@ function handle_keypress_event( // Create a bbox annotation around the initial_crop, // or the whole image if inital_crop does not exist - if (event_matches_keybind(keypress_event, ulabel.config.create_bbox_on_initial_crop)) { + if (event_matches_keybind(keypress_event, ulabel.config.create_bbox_on_initial_crop_keybind)) { if (current_subtask.state.annotation_mode === "bbox") { // Default to an annotation with size of image // Create the coordinates for the bbox's spatial payload diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index b354d624..2e1a1c66 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -1,5 +1,6 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; +import { Configuration } from "../configuration"; import { get_local_storage_item, set_local_storage_item } from "../utilities"; interface KeybindInfo { @@ -323,11 +324,11 @@ export class KeybindsToolboxItem extends ToolboxItem { }); keybinds.push({ - key: config.create_bbox_on_initial_crop, + key: config.create_bbox_on_initial_crop_keybind, label: "Create BBox on Crop", description: "Create bbox annotation on initial crop area", configurable: true, - config_key: "create_bbox_on_initial_crop", + config_key: "create_bbox_on_initial_crop_keybind", }); keybinds.push({ @@ -611,29 +612,8 @@ export class KeybindsToolboxItem extends ToolboxItem { * Reset all keybinds to their default values */ private reset_all_keybinds_to_default(): void { - // Get all keybind config keys - const keybind_keys = [ - "reset_zoom_keybind", - "create_point_annotation_keybind", - "delete_annotation_keybind", - "switch_subtask_keybind", - "toggle_annotation_mode_keybind", - "create_bbox_on_initial_crop", - "toggle_brush_mode_keybind", - "toggle_erase_mode_keybind", - "increase_brush_size_keybind", - "decrease_brush_size_keybind", - "fly_to_next_annotation_keybind", - "fly_to_previous_annotation_keybind", - "annotation_size_small_keybind", - "annotation_size_large_keybind", - "annotation_size_plus_keybind", - "annotation_size_minus_keybind", - "annotation_vanish_keybind", - ]; - // Reset all regular keybinds - for (const key of keybind_keys) { + for (const key of Configuration.KEYBIND_CONFIG_KEYS) { const default_value = this.get_default_keybind(key); this.ulabel.config[key] = default_value; } From f79e925bcf453a43a056cb48277958ae7bd9ed40 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 16:40:17 -0500 Subject: [PATCH 19/43] update active class keybinds on subtask switch, and allow setting of null class keybinds --- .github/tasks.md | 5 +++++ demo/multi-class.html | 4 ++-- src/index.js | 12 ++++++++---- src/initializer.ts | 4 +--- src/toolbox_items/keybinds.ts | 26 ++++++++++++++------------ 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 2d030d3b..5c4c4dae 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -44,4 +44,9 @@ - [x] Centralize keybind config property names in Configuration.KEYBIND_CONFIG_KEYS constant - [x] Make KEYBIND_CONFIG_KEYS dynamically generated from Configuration class properties - [x] Rename create_bbox_on_initial_crop to create_bbox_on_initial_crop_keybind for consistency +- [ ] Write e2e tests for the keybind toolbox item + - [ ] Ability to set keybind to a chord + - [ ] Ability to reset keybind + - [ ] Ability to set a class keybind +- [ ] Write a e2e test for each keybind diff --git a/demo/multi-class.html b/demo/multi-class.html index cad42002..2d18a3c2 100644 --- a/demo/multi-class.html +++ b/demo/multi-class.html @@ -51,7 +51,6 @@ "name": "Truck", "color": "orange", "id": 12, - "keybind": "3", }, ], "allowed_modes": ["bbox", "polygon", "contour", "polyline", "point", "tbar", "delete_polygon", "delete_bbox"], @@ -65,7 +64,8 @@ { "name": "Blurry", "color": "gray", - "id": 20 + "id": 20, + "keybind": "5", }, { "name": "Occluded", diff --git a/src/index.js b/src/index.js index 9109230e..0e2dcb6a 100644 --- a/src/index.js +++ b/src/index.js @@ -942,10 +942,14 @@ export class ULabel { update_current_class() { this.update_id_toolbox_display(); - // $("a.tbid-opt.sel").attr("href", "#"); - // $("a.tbid-opt.sel").removeClass("sel"); - // $("a#toolbox_sel_" + this.subtasks[this.state["current_subtask"]]["state"]["annotation_mode"]).addClass("sel"); - // $("a#toolbox_sel_" + this.subtasks[this.state["current_subtask"]]["state"]["annotation_mode"]).removeAttr("href"); + this.update_keybind_toolbox_display(); + } + + update_keybind_toolbox_display() { + // Call refresh_keybinds_display if keybind toolbox item exists + if (this.config.toolbox_order.includes(AllowedToolboxItem.Keybinds)) { + this.toolbox.items.find((item) => item.get_toolbox_item_type() === "Keybinds").refresh_keybinds_display(); + } } /** diff --git a/src/initializer.ts b/src/initializer.ts index ed885e6b..810651ca 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -82,9 +82,7 @@ function store_original_keybinds(ulabel: ULabel) { const subtask = ulabel.subtasks[subtask_key]; if (subtask.class_defs) { for (const class_def of subtask.class_defs) { - if (class_def.keybind !== null) { - original_class_keybinds[class_def.id] = class_def.keybind; - } + original_class_keybinds[class_def.id] = class_def?.keybind; } } } diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 2e1a1c66..01629cb1 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -2,6 +2,7 @@ import type { ULabel } from "../index"; import { ToolboxItem } from "../toolbox"; import { Configuration } from "../configuration"; import { get_local_storage_item, set_local_storage_item } from "../utilities"; +import { DELETE_CLASS_ID } from "../annotation"; interface KeybindInfo { key: string; @@ -426,16 +427,17 @@ export class KeybindsToolboxItem extends ToolboxItem { const original_class_keybinds = this.get_original_class_keybinds(); for (const class_def of current_subtask.class_defs) { - if (class_def.keybind !== null) { - keybinds.push({ - key: class_def.keybind, - label: class_def.name, - description: `Select class: ${class_def.name}`, - configurable: true, - class_id: class_def.id, - default_key: original_class_keybinds[class_def.id] || class_def.keybind, - }); - } + // Skip delete class + if (class_def.id === DELETE_CLASS_ID) continue; + + keybinds.push({ + key: class_def.keybind, + label: class_def.name, + description: `Select class: ${class_def.name}`, + configurable: true, + class_id: class_def.id, + default_key: original_class_keybinds[class_def.id] || class_def?.keybind, + }); } } @@ -760,7 +762,7 @@ export class KeybindsToolboxItem extends ToolboxItem { * Returns a unique string identifier for this toolbox item type */ public get_toolbox_item_type(): string { - return "keybinds"; + return "Keybinds"; } /** @@ -1048,7 +1050,7 @@ export class KeybindsToolboxItem extends ToolboxItem { /** * Refresh the keybinds display to show updated keys and collision detection */ - private refresh_keybinds_display(): void { + public refresh_keybinds_display(): void { const keybinds_list = $(".keybinds-list"); if (keybinds_list.length === 0) { return; From 3b2dff9f6d5bb78866db6e65dbab92b80513325c Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 16:59:16 -0500 Subject: [PATCH 20/43] fix rendering collapsed keybind sections --- src/listeners.ts | 2 +- src/toolbox_items/keybinds.ts | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/listeners.ts b/src/listeners.ts index 33c88de3..7f78f8c0 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -20,7 +20,7 @@ function event_matches_keybind( keyEvent: JQuery.KeyDownEvent | JQuery.KeyPressEvent, keybind: string, ): boolean { - if (!keybind) { + if (!keybind || typeof keybind !== "string") { return false; } diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 01629cb1..a196d796 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -648,14 +648,22 @@ export class KeybindsToolboxItem extends ToolboxItem { const class_keybinds = all_keybinds.filter((kb) => kb.class_id !== undefined); const other = all_keybinds.filter((kb) => !kb.configurable); + // Check collapsed states from localStorage + const configurable_collapsed = get_local_storage_item("ulabel_keybind_section_configurable_collapsed") === "true"; + const class_collapsed = get_local_storage_item("ulabel_keybind_section_class_collapsed") === "true"; + const other_collapsed = get_local_storage_item("ulabel_keybind_section_other_collapsed") === "true"; + // Configurable keybinds (non-class) if (configurable.length > 0) { + const toggle_icon = configurable_collapsed ? "▶" : "▼"; + const section_class = configurable_collapsed ? " collapsed" : ""; + keybinds_html += `
Configurable Keybinds - + ${toggle_icon}
-
+
`; for (const keybind of configurable) { const has_collision = this.has_collision(keybind.key, all_keybinds); @@ -680,12 +688,15 @@ export class KeybindsToolboxItem extends ToolboxItem { // Class keybinds if (class_keybinds.length > 0) { + const toggle_icon = class_collapsed ? "▶" : "▼"; + const section_class = class_collapsed ? " collapsed" : ""; + keybinds_html += `
Class Keybinds - + ${toggle_icon}
-
+
`; for (const keybind of class_keybinds) { const has_collision = this.has_collision(keybind.key, all_keybinds); @@ -710,12 +721,15 @@ export class KeybindsToolboxItem extends ToolboxItem { // Other keybinds if (other.length > 0) { + const toggle_icon = other_collapsed ? "▶" : "▼"; + const section_class = other_collapsed ? " collapsed" : ""; + keybinds_html += `
Other - + ${toggle_icon}
-
+
`; for (const keybind of other) { const has_collision = this.has_collision(keybind.key, all_keybinds); From a0d9b7252d4f6f313943c4ba396ade8412bd5ba8 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 17:19:38 -0500 Subject: [PATCH 21/43] fix reseting class keybind to null --- .github/copilot-instructions.md | 1 + .github/tasks.md | 1 + src/toolbox_items/keybinds.ts | 17 ++++++----------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7a01a701..79696bb2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,7 @@ - Ensure changes build successfully by running `npm run build`. - Do not add new dependencies unless given explicit permission. - Do not modify the `package.json` or `package-lock.json` files unless instructed. +- Use `log_message` instead of `console.` for logging messages. ## Task Tracking diff --git a/.github/tasks.md b/.github/tasks.md index 5c4c4dae..72baa569 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -44,6 +44,7 @@ - [x] Centralize keybind config property names in Configuration.KEYBIND_CONFIG_KEYS constant - [x] Make KEYBIND_CONFIG_KEYS dynamically generated from Configuration class properties - [x] Rename create_bbox_on_initial_crop to create_bbox_on_initial_crop_keybind for consistency +- [ ] Replace any console outputs with `log_message` - [ ] Write e2e tests for the keybind toolbox item - [ ] Ability to set keybind to a chord - [ ] Ability to reset keybind diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index a196d796..e238ffb2 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -11,7 +11,6 @@ interface KeybindInfo { configurable: boolean; config_key?: string; class_id?: number; // For class keybinds - default_key?: string; // Default value for the keybind } /** @@ -423,9 +422,6 @@ export class KeybindsToolboxItem extends ToolboxItem { // Add class keybinds const current_subtask = this.ulabel.get_current_subtask(); if (current_subtask && current_subtask.class_defs) { - // Get original keybinds from subtask config before any customization - const original_class_keybinds = this.get_original_class_keybinds(); - for (const class_def of current_subtask.class_defs) { // Skip delete class if (class_def.id === DELETE_CLASS_ID) continue; @@ -436,7 +432,6 @@ export class KeybindsToolboxItem extends ToolboxItem { description: `Select class: ${class_def.name}`, configurable: true, class_id: class_def.id, - default_key: original_class_keybinds[class_def.id] || class_def?.keybind, }); } } @@ -585,11 +580,12 @@ export class KeybindsToolboxItem extends ToolboxItem { /** * Reset a class keybind to its default value */ - private reset_class_keybind_to_default(class_id: number, original_keybind: string): void { + private reset_class_keybind_to_default(class_id: number): void { const current_subtask = this.ulabel.get_current_subtask(); const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); if (class_def) { - class_def.keybind = original_keybind; + const original_class_keybinds = this.get_original_class_keybinds(); + class_def.keybind = original_class_keybinds[class_id]; } // Remove from localStorage @@ -703,8 +699,8 @@ export class KeybindsToolboxItem extends ToolboxItem { const is_customized = this.is_class_keybind_customized(keybind.class_id); const collision_class = has_collision ? " collision" : ""; const customized_class = is_customized ? " customized" : ""; - const display_key = keybind.key !== null && keybind.key !== undefined ? keybind.key : "none"; - const reset_button = is_customized ? `` : ""; + const display_key = keybind.key != null ? keybind.key : "none"; + const reset_button = is_customized ? `` : ""; keybinds_html += `
@@ -929,11 +925,10 @@ export class KeybindsToolboxItem extends ToolboxItem { const button = $(e.currentTarget); const config_key = button.data("config-key") as string; const class_id = button.data("class-id") as number; - const original_keybind = button.data("original-keybind") as string; if (class_id !== undefined) { // Reset class keybind - this.reset_class_keybind_to_default(class_id, original_keybind); + this.reset_class_keybind_to_default(class_id); } else if (config_key) { // Reset regular keybind this.reset_keybind_to_default(config_key); From 8556c65ea3be41064d9c56c8ae0a021b7a3ddcc9 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 17:23:21 -0500 Subject: [PATCH 22/43] use log message --- .github/tasks.md | 2 +- src/toolbox_items/keybinds.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 72baa569..277fc85d 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -44,7 +44,7 @@ - [x] Centralize keybind config property names in Configuration.KEYBIND_CONFIG_KEYS constant - [x] Make KEYBIND_CONFIG_KEYS dynamically generated from Configuration class properties - [x] Rename create_bbox_on_initial_crop to create_bbox_on_initial_crop_keybind for consistency -- [ ] Replace any console outputs with `log_message` +- [x] Replace any console outputs with `log_message` - [ ] Write e2e tests for the keybind toolbox item - [ ] Ability to set keybind to a chord - [ ] Ability to reset keybind diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index e238ffb2..2f391735 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -3,6 +3,7 @@ import { ToolboxItem } from "../toolbox"; import { Configuration } from "../configuration"; import { get_local_storage_item, set_local_storage_item } from "../utilities"; import { DELETE_CLASS_ID } from "../annotation"; +import { log_message, LogLevel } from "../error_logging"; interface KeybindInfo { key: string; @@ -532,7 +533,7 @@ export class KeybindsToolboxItem extends ToolboxItem { localStorage.removeItem("ulabel_custom_keybinds"); } } catch (e) { - console.error("Failed to update custom keybinds:", e); + log_message(`Failed to update custom keybinds: ${e}`, LogLevel.ERROR, true); } } } @@ -601,7 +602,7 @@ export class KeybindsToolboxItem extends ToolboxItem { localStorage.removeItem("ulabel_custom_class_keybinds"); } } catch (e) { - console.error("Failed to update custom class keybinds:", e); + log_message(`Failed to update custom class keybinds: ${e}`, LogLevel.ERROR, true); } } } From 729d50bd3f88e895c3621c1e84a982ddab562194 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 17:28:17 -0500 Subject: [PATCH 23/43] move reset button to the left of the keybind --- src/toolbox_items/keybinds.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 2f391735..9f24857a 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -674,8 +674,8 @@ export class KeybindsToolboxItem extends ToolboxItem {
${keybind.label}
- ${display_key} ${reset_button} + ${display_key}
`; @@ -707,8 +707,8 @@ export class KeybindsToolboxItem extends ToolboxItem {
${keybind.label}
- ${display_key} ${reset_button} + ${display_key}
`; From d87c2dffc873472016e554cfe15f15dd487e438c Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 17:41:09 -0500 Subject: [PATCH 24/43] separate reset zoom and show full image --- .github/tasks.md | 5 +++++ api_spec.md | 6 +++++- src/configuration.ts | 2 ++ src/listeners.ts | 8 ++++---- src/toolbox.ts | 4 ++-- src/toolbox_items/keybinds.ts | 8 ++++++++ 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 277fc85d..820b5f5e 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -45,6 +45,11 @@ - [x] Make KEYBIND_CONFIG_KEYS dynamically generated from Configuration class properties - [x] Rename create_bbox_on_initial_crop to create_bbox_on_initial_crop_keybind for consistency - [x] Replace any console outputs with `log_message` +- [x] Replace the "reset_zoom_keybind" with two separate keybinds: + - [x] Add "show_full_image_keybind" property to Configuration + - [x] Update listeners.ts to use both keybinds independently + - [x] Update toolbox.ts to use both keybinds independently + - [x] Update api_spec.md to document both keybinds - [ ] Write e2e tests for the keybind toolbox item - [ ] Ability to set keybind to a chord - [ ] Ability to reset keybind diff --git a/api_spec.md b/api_spec.md index 5b98bb26..a3499c87 100644 --- a/api_spec.md +++ b/api_spec.md @@ -53,6 +53,7 @@ class ULabel({ distance_filter_toolbox_item: FilterDistanceConfig, image_filters_toolbox_item: ImageFiltersConfig, reset_zoom_keybind: string, + show_full_image_keybind: string, create_point_annotation_keybind: string, default_annotation_size: number, delete_annotation_keybind: string, @@ -424,7 +425,10 @@ The `AnnotationList` toolbox item displays all annotations in the current subtas This toolbox item requires no configuration and can be added to the `toolbox_order` array using `AllowedToolboxItem.AnnotationList`. ### `reset_zoom_keybind` -Keybind to reset the zoom level. Must be a letter, and the lowercase version of the letter will set the zoom level to the `initial_crop`, while the capitalized version will show the full image. Default is `r`. +Keybind to reset the zoom level to the `initial_crop`. Default is `r`. + +### `show_full_image_keybind` +Keybind to set the zoom level to show the full image. Default is `shift+r`. ### `create_point_annotation_keybind` Keybind to create a point annotation at the mouse location. Default is `c`. Requires the active subtask to have a `point` mode. diff --git a/src/configuration.ts b/src/configuration.ts index b61a31be..a1146b5f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -193,6 +193,8 @@ export class Configuration { public reset_zoom_keybind: string = "r"; + public show_full_image_keybind: string = "shift+r"; + public create_point_annotation_keybind: string = "c"; public default_annotation_size: number = 6; diff --git a/src/listeners.ts b/src/listeners.ts index 7f78f8c0..b5f74910 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -132,14 +132,14 @@ function handle_keypress_event( return; } - // Show initial crop (lowercase reset zoom keybind) - if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind.toLowerCase())) { + // Reset zoom to initial crop + if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind)) { ulabel.show_initial_crop(); return; } - // Show whole image (uppercase reset zoom keybind) - if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind.toUpperCase())) { + // Show full image + if (event_matches_keybind(keypress_event, ulabel.config.show_full_image_keybind)) { ulabel.show_whole_image(); return; } diff --git a/src/toolbox.ts b/src/toolbox.ts index ef4d9837..be6ffac7 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -945,10 +945,10 @@ export class ZoomPanToolboxItem extends ToolboxItem { }); $(document).on("keypress.ulabel", (e) => { - if (e.key == this.ulabel.config.reset_zoom_keybind.toLowerCase()) { + if (e.key == this.ulabel.config.reset_zoom_keybind) { document.getElementById("recenter-button").click(); } - if (e.key == this.ulabel.config.reset_zoom_keybind.toUpperCase()) { + if (e.key == this.ulabel.config.show_full_image_keybind) { document.getElementById("recenter-whole-image-button").click(); } }); diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 9f24857a..bf3c1d86 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -292,6 +292,14 @@ export class KeybindsToolboxItem extends ToolboxItem { config_key: "reset_zoom_keybind", }); + keybinds.push({ + key: config.show_full_image_keybind, + label: "Show Full Image", + description: "Show the full image", + configurable: true, + config_key: "show_full_image_keybind", + }); + keybinds.push({ key: config.create_point_annotation_keybind, label: "Create Point", From a2564b6567071f4b9d0b0dadb8e83beb9f3f4cbe Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 29 Oct 2025 17:45:49 -0500 Subject: [PATCH 25/43] check if user keybinds are actually different from default --- src/toolbox_items/keybinds.ts | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index bf3c1d86..2bae8339 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -1012,14 +1012,55 @@ export class KeybindsToolboxItem extends ToolboxItem { const class_def = current_subtask.class_defs.find((cd) => cd.id === class_id); if (class_def) { class_def.keybind = new_key; - // Save class keybind to localStorage - this.save_class_keybind_to_storage(class_id, new_key); + + // Only save to localStorage if different from default + const original_class_keybinds = this.get_original_class_keybinds(); + const default_value = original_class_keybinds[class_id]; + if (new_key !== default_value) { + this.save_class_keybind_to_storage(class_id, new_key); + } else { + // If it matches the default, remove it from localStorage + const stored = get_local_storage_item("ulabel_custom_class_keybinds"); + if (stored) { + try { + const custom_class_keybinds = JSON.parse(stored); + delete custom_class_keybinds[class_id]; + if (Object.keys(custom_class_keybinds).length > 0) { + set_local_storage_item("ulabel_custom_class_keybinds", JSON.stringify(custom_class_keybinds)); + } else { + localStorage.removeItem("ulabel_custom_class_keybinds"); + } + } catch (e) { + log_message(`Failed to update custom class keybinds: ${e}`, LogLevel.ERROR, true); + } + } + } } } else { // Update the config this.ulabel.config[config_key] = new_key; - // Save regular keybind to localStorage - this.save_keybind_to_storage(config_key, new_key); + + // Only save to localStorage if different from default + const default_value = this.get_default_keybind(config_key); + if (new_key !== default_value) { + this.save_keybind_to_storage(config_key, new_key); + } else { + // If it matches the default, remove it from localStorage + const stored = get_local_storage_item("ulabel_custom_keybinds"); + if (stored) { + try { + const custom_keybinds = JSON.parse(stored); + delete custom_keybinds[config_key]; + if (Object.keys(custom_keybinds).length > 0) { + set_local_storage_item("ulabel_custom_keybinds", JSON.stringify(custom_keybinds)); + } else { + localStorage.removeItem("ulabel_custom_keybinds"); + } + } catch (e) { + log_message(`Failed to update custom keybinds: ${e}`, LogLevel.ERROR, true); + } + } + } } // Update the display From 6864e767d0ce806b717f7eb452163daee9ccf8c6 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 08:06:11 -0500 Subject: [PATCH 26/43] add some tests --- .github/tasks.md | 7 +- tests/e2e/keybinds.spec.js | 236 +++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/keybinds.spec.js diff --git a/.github/tasks.md b/.github/tasks.md index 820b5f5e..717ff7df 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -51,8 +51,9 @@ - [x] Update toolbox.ts to use both keybinds independently - [x] Update api_spec.md to document both keybinds - [ ] Write e2e tests for the keybind toolbox item - - [ ] Ability to set keybind to a chord - - [ ] Ability to reset keybind - - [ ] Ability to set a class keybind + - [x] Ability to set keybind to a chord + - [x] Ability to reset keybind + - [x] Ability to set a class keybind + - [ ] Run tests to verify they pass - [ ] Write a e2e test for each keybind diff --git a/tests/e2e/keybinds.spec.js b/tests/e2e/keybinds.spec.js new file mode 100644 index 00000000..09917e64 --- /dev/null +++ b/tests/e2e/keybinds.spec.js @@ -0,0 +1,236 @@ +// End-to-end tests for keybind toolbox item +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; + +test.describe("Keybinds Toolbox Item", () => { + test("should display keybinds, allow editing with chords, reset keybinds, and set class keybinds", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Expand the keybinds toolbox item + const keybindsHeader = page.locator(".keybinds-header"); + await expect(keybindsHeader).toBeVisible(); + await keybindsHeader.click(); + + // Check that keybinds list is visible + const keybindsList = page.locator(".keybinds-list"); + await expect(keybindsList).toBeVisible(); + + // Check that some keybinds are present + const keybindItems = page.locator(".keybind-item"); + const count = await keybindItems.count(); + expect(count).toBeGreaterThan(0); + + // Find a configurable keybind (e.g., "Delete Annotation") + const deleteKeybindItem = page.locator(".keybind-item").filter({ hasText: "Delete Annotation" }); + await expect(deleteKeybindItem).toBeVisible(); + + // Get the original keybind value + const deleteKeybindKey = deleteKeybindItem.locator(".keybind-key"); + const originalValue = await deleteKeybindKey.textContent(); + + // --- Test: Set keybind to a chord --- + + // Click to start editing + await deleteKeybindKey.click(); + await expect(deleteKeybindKey).toHaveClass(/editing/); + await expect(deleteKeybindKey).toHaveText("Press key..."); + + // Press a chord (shift+x) + await page.keyboard.press("Shift+X"); + + // Check that the keybind was updated + await expect(deleteKeybindKey).toHaveText("shift+x"); + await expect(deleteKeybindKey).not.toHaveClass(/editing/); + + // Check that the keybind is marked as customized (yellow highlight) + await expect(deleteKeybindKey).toHaveClass(/customized/); + + // Check that reset button is now visible + const resetButton = deleteKeybindItem.locator(".keybind-reset-btn"); + await expect(resetButton).toBeVisible(); + + // Verify the keybind works by checking localStorage + const customKeybinds = await page.evaluate(() => { + const stored = localStorage.getItem("ulabel_custom_keybinds"); + return stored ? JSON.parse(stored) : {}; + }); + expect(customKeybinds).toHaveProperty("delete_annotation_keybind", "shift+x"); + + // --- Test: Reset keybind to default --- + + // Click the reset button + await resetButton.click(); + + // Check that the keybind was reset to original value + await expect(deleteKeybindKey).toHaveText(originalValue); + await expect(deleteKeybindKey).not.toHaveClass(/customized/); + + // Check that reset button is no longer visible + await expect(resetButton).not.toBeVisible(); + + // Verify it was removed from localStorage + const customKeybindsAfterReset = await page.evaluate(() => { + const stored = localStorage.getItem("ulabel_custom_keybinds"); + return stored ? JSON.parse(stored) : {}; + }); + expect(customKeybindsAfterReset).not.toHaveProperty("delete_annotation_keybind"); + + // --- Test: Set a class keybind --- + + // Expand the class keybinds section + const classSection = page.locator(".keybind-category").filter({ hasText: "Class Keybinds" }); + await expect(classSection).toBeVisible(); + await classSection.click(); + + // Find the first class keybind + const classKeybindItems = page.locator(".keybind-section-items[data-section='class'] .keybind-item"); + const classCount = await classKeybindItems.count(); + + if (classCount > 0) { + const firstClassKeybind = classKeybindItems.first(); + const classKeybindKey = firstClassKeybind.locator(".keybind-key"); + const originalClassValue = await classKeybindKey.textContent(); + + // Click to start editing + await classKeybindKey.click(); + await expect(classKeybindKey).toHaveClass(/editing/); + + // Press a simple key + await page.keyboard.press("q"); + + // Check that the class keybind was updated + await expect(classKeybindKey).toHaveText("q"); + await expect(classKeybindKey).not.toHaveClass(/editing/); + await expect(classKeybindKey).toHaveClass(/customized/); + + // Get the class ID and verify it was saved + const classId = await classKeybindKey.getAttribute("data-class-id"); + const customClassKeybinds = await page.evaluate(() => { + const stored = localStorage.getItem("ulabel_custom_class_keybinds"); + return stored ? JSON.parse(stored) : {}; + }); + expect(customClassKeybinds).toHaveProperty(classId, "q"); + + // Reset the class keybind + const classResetButton = firstClassKeybind.locator(".keybind-reset-btn"); + await classResetButton.click(); + + // Verify it was reset + await expect(classKeybindKey).toHaveText(originalClassValue); + await expect(classKeybindKey).not.toHaveClass(/customized/); + } + + // --- Test: Cancel editing with Escape --- + + // Try editing again but cancel with Escape + await deleteKeybindKey.click(); + await expect(deleteKeybindKey).toHaveClass(/editing/); + await page.keyboard.press("Escape"); + + // Should return to original value + await expect(deleteKeybindKey).toHaveText(originalValue); + await expect(deleteKeybindKey).not.toHaveClass(/editing/); + + // --- Test: Reset All to Default --- + + // Set a few keybinds to custom values + const createPointItem = page.locator(".keybind-item").filter({ hasText: "Create Point" }); + const createPointKey = createPointItem.locator(".keybind-key"); + await createPointKey.click(); + await page.keyboard.press("p"); + + const switchSubtaskItem = page.locator(".keybind-item").filter({ hasText: "Switch Subtask" }); + const switchSubtaskKey = switchSubtaskItem.locator(".keybind-key"); + await switchSubtaskKey.click(); + await page.keyboard.press("ctrl+s"); + + // Verify both have custom values + await expect(createPointKey).toHaveClass(/customized/); + await expect(switchSubtaskKey).toHaveClass(/customized/); + + // Click "Reset All to Default" button + const resetAllButton = page.locator(".keybinds-reset-all-btn"); + await expect(resetAllButton).toBeVisible(); + + // Accept the confirmation dialog + page.on("dialog", (dialog) => dialog.accept()); + await resetAllButton.click(); + + // Verify all keybinds are no longer customized + await expect(createPointKey).not.toHaveClass(/customized/); + await expect(switchSubtaskKey).not.toHaveClass(/customized/); + await expect(deleteKeybindKey).not.toHaveClass(/customized/); + + // Verify localStorage is cleared + const finalCustomKeybinds = await page.evaluate(() => { + return localStorage.getItem("ulabel_custom_keybinds"); + }); + expect(finalCustomKeybinds).toBeNull(); + }); + + test("should detect and highlight keybind collisions", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Expand the keybinds toolbox item + await page.locator(".keybinds-header").click(); + + // Get two different keybinds + const deleteKeybindItem = page.locator(".keybind-item").filter({ hasText: "Delete Annotation" }); + const deleteKeybindKey = deleteKeybindItem.locator(".keybind-key"); + const originalDeleteValue = await deleteKeybindKey.textContent(); + + const createPointItem = page.locator(".keybind-item").filter({ hasText: "Create Point" }); + const createPointKey = createPointItem.locator(".keybind-key"); + const createPointValue = await createPointKey.textContent(); + + // Set delete keybind to the same value as create point keybind + await deleteKeybindKey.click(); + await page.keyboard.press(createPointValue); + + // Both should now have the collision class + await expect(deleteKeybindKey).toHaveClass(/collision/); + await expect(createPointKey).toHaveClass(/collision/); + + // Reset to remove collision + const resetButton = deleteKeybindItem.locator(".keybind-reset-btn"); + await resetButton.click(); + + // Neither should have collision class now + await expect(deleteKeybindKey).not.toHaveClass(/collision/); + await expect(createPointKey).not.toHaveClass(/collision/); + }); + + test("should collapse and expand sections with state persistence", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Expand the keybinds toolbox item + await page.locator(".keybinds-header").click(); + + // Find the "Configurable Keybinds" section + const configurableSection = page.locator(".keybind-category").filter({ hasText: "Configurable Keybinds" }); + const configurableItems = page.locator(".keybind-section-items[data-section='configurable']"); + + // Should be expanded by default (check localStorage is "false" or null) + await expect(configurableItems).not.toHaveClass(/collapsed/); + + // Click to collapse + await configurableSection.click(); + await expect(configurableItems).toHaveClass(/collapsed/); + + // Verify localStorage + const collapsed = await page.evaluate(() => { + return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); + }); + expect(collapsed).toBe("true"); + + // Click to expand again + await configurableSection.click(); + await expect(configurableItems).not.toHaveClass(/collapsed/); + + // Verify localStorage + const expanded = await page.evaluate(() => { + return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); + }); + expect(expanded).toBe("false"); + }); +}); From 16f2e147c58a1a308798d9e104f7b2511685f64d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 08:32:16 -0500 Subject: [PATCH 27/43] fix tests --- tests/e2e/keybinds.spec.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/e2e/keybinds.spec.js b/tests/e2e/keybinds.spec.js index 09917e64..86d0cb41 100644 --- a/tests/e2e/keybinds.spec.js +++ b/tests/e2e/keybinds.spec.js @@ -51,10 +51,11 @@ test.describe("Keybinds Toolbox Item", () => { // Verify the keybind works by checking localStorage const customKeybinds = await page.evaluate(() => { - const stored = localStorage.getItem("ulabel_custom_keybinds"); - return stored ? JSON.parse(stored) : {}; + return localStorage.getItem("ulabel_custom_keybinds"); }); - expect(customKeybinds).toHaveProperty("delete_annotation_keybind", "shift+x"); + // Parse twice because localStorage stores a JSON string of a JSON string + const parsedKeybinds = customKeybinds ? JSON.parse(JSON.parse(customKeybinds)) : {}; + expect(parsedKeybinds).toHaveProperty("delete_annotation_keybind", "shift+x"); // --- Test: Reset keybind to default --- @@ -70,18 +71,14 @@ test.describe("Keybinds Toolbox Item", () => { // Verify it was removed from localStorage const customKeybindsAfterReset = await page.evaluate(() => { - const stored = localStorage.getItem("ulabel_custom_keybinds"); - return stored ? JSON.parse(stored) : {}; + return localStorage.getItem("ulabel_custom_keybinds"); }); - expect(customKeybindsAfterReset).not.toHaveProperty("delete_annotation_keybind"); + // Parse twice because localStorage stores a JSON string of a JSON string + const parsedKeybindsAfterReset = customKeybindsAfterReset ? JSON.parse(JSON.parse(customKeybindsAfterReset)) : {}; + expect(parsedKeybindsAfterReset).not.toHaveProperty("delete_annotation_keybind"); // --- Test: Set a class keybind --- - // Expand the class keybinds section - const classSection = page.locator(".keybind-category").filter({ hasText: "Class Keybinds" }); - await expect(classSection).toBeVisible(); - await classSection.click(); - // Find the first class keybind const classKeybindItems = page.locator(".keybind-section-items[data-section='class'] .keybind-item"); const classCount = await classKeybindItems.count(); @@ -106,10 +103,11 @@ test.describe("Keybinds Toolbox Item", () => { // Get the class ID and verify it was saved const classId = await classKeybindKey.getAttribute("data-class-id"); const customClassKeybinds = await page.evaluate(() => { - const stored = localStorage.getItem("ulabel_custom_class_keybinds"); - return stored ? JSON.parse(stored) : {}; + return localStorage.getItem("ulabel_custom_class_keybinds"); }); - expect(customClassKeybinds).toHaveProperty(classId, "q"); + // Parse twice because localStorage stores a JSON string of a JSON string + const parsedClassKeybinds = customClassKeybinds ? JSON.parse(JSON.parse(customClassKeybinds)) : {}; + expect(parsedClassKeybinds).toHaveProperty(classId, "q"); // Reset the class keybind const classResetButton = firstClassKeybind.locator(".keybind-reset-btn"); @@ -142,7 +140,7 @@ test.describe("Keybinds Toolbox Item", () => { const switchSubtaskItem = page.locator(".keybind-item").filter({ hasText: "Switch Subtask" }); const switchSubtaskKey = switchSubtaskItem.locator(".keybind-key"); await switchSubtaskKey.click(); - await page.keyboard.press("ctrl+s"); + await page.keyboard.press("Control+S"); // Verify both have custom values await expect(createPointKey).toHaveClass(/customized/); @@ -217,20 +215,20 @@ test.describe("Keybinds Toolbox Item", () => { await configurableSection.click(); await expect(configurableItems).toHaveClass(/collapsed/); - // Verify localStorage + // Verify localStorage (stored as double-stringified JSON) const collapsed = await page.evaluate(() => { return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); }); - expect(collapsed).toBe("true"); + expect(collapsed).toBe(JSON.stringify("true")); // Click to expand again await configurableSection.click(); await expect(configurableItems).not.toHaveClass(/collapsed/); - // Verify localStorage + // Verify localStorage (stored as double-stringified JSON) const expanded = await page.evaluate(() => { return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); }); - expect(expanded).toBe("false"); + expect(expanded).toBe(JSON.stringify("false")); }); }); From 97ad330ac621d2620367092ebfecd203f902f758 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 10:05:00 -0500 Subject: [PATCH 28/43] tests for each keybinds --- .github/copilot-instructions.md | 10 +- .github/tasks.md | 24 +- tests/e2e/keybind-functionality.spec.js | 495 ++++++++++++++++++++++++ tests/e2e/keybinds.spec.js | 1 - 4 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/keybind-functionality.spec.js diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 79696bb2..a2a7f7ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,11 +5,15 @@ - Ensure changes build successfully by running `npm run build`. - Do not add new dependencies unless given explicit permission. - Do not modify the `package.json` or `package-lock.json` files unless instructed. -- Use `log_message` instead of `console.` for logging messages. - ## Task Tracking - Always start each step by referencing and updating your tasks in `.github/tasks.md`. - Use checkboxes to track progress. - Work on one task at a time. -- Do not mark tasks as complete until they are fully done. Ask for confirmation if unsure. \ No newline at end of file +- Do not mark tasks as complete until they are fully done. Ask for confirmation if unsure. + +## Repository Style +- Follow existing code style and conventions. +- Use `snake_case` for variable and function names. +- Use `PascalCase` for class names. +- Use `log_message` instead of `console.` for logging messages. \ No newline at end of file diff --git a/.github/tasks.md b/.github/tasks.md index 717ff7df..1cdb8238 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -50,10 +50,28 @@ - [x] Update listeners.ts to use both keybinds independently - [x] Update toolbox.ts to use both keybinds independently - [x] Update api_spec.md to document both keybinds -- [ ] Write e2e tests for the keybind toolbox item +- [x] Write e2e tests for the keybind toolbox item - [x] Ability to set keybind to a chord - [x] Ability to reset keybind - [x] Ability to set a class keybind - - [ ] Run tests to verify they pass -- [ ] Write a e2e test for each keybind + - [x] Run tests to verify they pass +- [x] Write a e2e test for each keybind + - [x] reset_zoom_keybind (r) + - [x] show_full_image_keybind (shift+r) + - [x] create_point_annotation_keybind (c) + - [x] delete_annotation_keybind (d) + - [x] switch_subtask_keybind (z) + - [x] toggle_annotation_mode_keybind (u) + - [x] create_bbox_on_initial_crop_keybind (f) + - [x] toggle_brush_mode_keybind (g) + - [x] toggle_erase_mode_keybind (e) + - [x] increase_brush_size_keybind (]) + - [x] decrease_brush_size_keybind ([) + - [x] annotation_size_small_keybind (s) + - [x] annotation_size_large_keybind (l) + - [x] annotation_size_plus_keybind (=) + - [x] annotation_size_minus_keybind (-) + - [x] annotation_vanish_keybind (v) + - [x] fly_to_next_annotation_keybind (tab) + - [x] fly_to_previous_annotation_keybind (shift+tab) diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js new file mode 100644 index 00000000..6907eea6 --- /dev/null +++ b/tests/e2e/keybind-functionality.spec.js @@ -0,0 +1,495 @@ +// End-to-end tests for individual keybind functionality +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; +import { get_annotation_count, get_annotation_by_index } from "../testing-utils/annotation_utils"; +import { draw_bbox } from "../testing-utils/drawing_utils"; +import { get_current_subtask_key } from "../testing-utils/subtask_utils"; + +/** + * Helper function to press a keybind with modifiers + * @param {import('@playwright/test').Page} page - Playwright page object + * @param {string} keybind - The keybind string (e.g., "r", "shift+r", "ctrl+alt+d") + */ +async function press_keybind(page, keybind) { + const keys = keybind.toLowerCase().split("+"); + + // Helper to normalize key names for Playwright + const normalize_key = (key) => { + // Playwright expects "Tab" not "tab", "Space" not "space", etc. + if (key === "tab") return "Tab"; + if (key === "space") return "Space"; + if (key === "enter") return "Enter"; + if (key === "escape") return "Escape"; + if (key === "ctrl") return "Control"; + if (key === "meta" || key === "cmd") return "Meta"; + if (key === "shift") return "Shift"; + if (key === "alt") return "Alt"; + return key; + }; + + // normalize each key and recombine + const normalized_keys = keys.map(normalize_key); + await page.keyboard.press(normalized_keys.join("+")); +} + +/** + * Helper function to get the current keybind value from the keybinds toolbox item + * @param {import('@playwright/test').Page} page - Playwright page object + * @param {string} labelText - The label text to search for (e.g., "Reset Zoom") + * @returns {Promise} The current keybind value + */ +async function get_keybind_value(page, label_text) { + // Ensure keybinds toolbox is expanded + const keybinds_header = page.locator(".keybinds-header"); + const keybinds_list = page.locator(".keybinds-list"); + + // Check if already expanded + const is_expanded = await keybinds_list.isVisible().catch(() => false); + if (!is_expanded) { + await keybinds_header.click(); + await expect(keybinds_list).toBeVisible(); + } + + // Find the keybind item by label text + const keybind_item = page.locator(".keybind-item").filter({ hasText: label_text }); + await expect(keybind_item).toBeVisible(); + + // Get the keybind value + const keybind_key = keybind_item.locator(".keybind-key"); + const keybind = await keybind_key.textContent(); + + return keybind.trim(); +} + +test.describe("Keybind Functionality Tests", () => { + test("reset_zoom_keybind should reset zoom to fit image", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Reset Zoom"); + + // Get initial zoom level + const initial_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + + // Zoom in to change the zoom level + await page.mouse.move(400, 400); + await page.mouse.wheel(0, -100); // Zoom in + + // Wait for zoom to update + await page.waitForTimeout(100); + + // Verify zoom changed + const zoomed_in = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + expect(zoomed_in).not.toBe(initial_zoom); + + // Press the reset zoom keybind + await press_keybind(page, keybind); + + // Wait for zoom to update + await page.waitForTimeout(100); + + // Verify zoom was reset + const reset_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + expect(reset_zoom).toBe(initial_zoom); + }); + + test("show_full_image_keybind should zoom to show full image", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Show Full Image"); + + // Zoom in to change the view + await page.mouse.move(400, 400); + await page.mouse.wheel(0, -200); // Zoom in significantly + + // Wait for zoom to update + await page.waitForTimeout(100); + + // Get the current zoom and position + const before_zoom = await page.evaluate(() => { + return { + zoom: window.ulabel.state.zoom_val, + x: window.ulabel.state.px_per_px, + y: window.ulabel.state.py_per_px, + }; + }); + + // Press the show full image keybind + await press_keybind(page, keybind); + + // Wait for zoom to update + await page.waitForTimeout(100); + + // Verify view changed (zoom should be different to show full image) + const after_zoom = await page.evaluate(() => { + return { + zoom: window.ulabel.state.zoom_val, + x: window.ulabel.state.px_per_px, + y: window.ulabel.state.py_per_px, + }; + }); + + // The zoom should have changed to show the full image + expect(after_zoom.zoom).not.toBe(before_zoom.zoom); + }); + + test("create_point_annotation_keybind should create a point annotation at cursor", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Create Point"); + + // Switch to point annotation mode first + const point_mode_button = page.locator("#md-btn--point"); + await point_mode_button.click(); + + // Wait for mode to switch + await page.waitForTimeout(100); + + // Get initial annotation count + const initial_count = await get_annotation_count(page); + + // Move mouse to a specific location on the image + const canvas = page.locator("#annbox"); + await canvas.hover({ position: { x: 300, y: 300 } }); + + // Press the create point keybind + await press_keybind(page, keybind); + + // Wait for annotation to be created + await page.waitForTimeout(200); + + // Verify a new annotation was created + const new_count = await get_annotation_count(page); + expect(new_count).toBe(initial_count + 1); + + // Verify it's a point annotation + const last_annotation = await get_annotation_by_index(page, new_count - 1); + expect(last_annotation.spatial_type).toBe("point"); + }); + + test("delete_annotation_keybind should delete the hovered annotation", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Delete Annotation"); + + // Create a bbox annotation - this leaves the mouse at the bottom right + await draw_bbox(page, [200, 200], [400, 400]); + + // Get the annotation before deletion + const annotation = await get_annotation_by_index(page, 0); + expect(annotation.deprecated).toBe(false); + + // Move mouse to center of bbox to trigger hover + await page.mouse.move(300, 300); + await page.waitForTimeout(200); + + // Press the delete keybind + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify the annotation is now deprecated + const annotation_after_delete = await get_annotation_by_index(page, 0); + expect(annotation_after_delete.deprecated).toBe(true); + }); + + test("switch_subtask_keybind should switch to the next subtask", async ({ page }) => { + // Use multi-class demo which has multiple subtasks + await page.goto("demo/multi-class.html"); + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Switch Subtask"); + + // Get initial subtask + const initial_subtask = await get_current_subtask_key(page); + expect(initial_subtask).toBe("car_detection"); + + // Press the switch subtask keybind + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify we switched to the next subtask + const new_subtask = await get_current_subtask_key(page); + expect(new_subtask).toBe("frame_review"); + + // Press again to cycle back + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify we cycled back to the first subtask + const final_subtask = await get_current_subtask_key(page); + expect(final_subtask).toBe("car_detection"); + }); + + test("toggle_annotation_mode_keybind should cycle through annotation modes", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Toggle Annotation Mode"); + + // Helper to get current annotation mode + const get_current_mode = async () => { + return await page.evaluate(() => { + return window.ulabel.get_current_subtask().state.annotation_mode; + }); + }; + + // Get initial mode (should be bbox by default) + const initial_mode = await get_current_mode(); + expect(initial_mode).toBe("bbox"); + + // Press the keybind to toggle to next mode + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify we switched to a different mode + const second_mode = await get_current_mode(); + expect(second_mode).not.toBe(initial_mode); + + // Press again to toggle to another mode + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify we switched again + const third_mode = await get_current_mode(); + expect(third_mode).not.toBe(second_mode); + + // Keep pressing until we cycle back to the original mode + let current_mode = third_mode; + let attempts = 0; + const max_attempts = 10; // Safety limit + while (current_mode !== initial_mode && attempts < max_attempts) { + await press_keybind(page, keybind); + await page.waitForTimeout(200); + current_mode = await get_current_mode(); + attempts++; + } + + // Verify we cycled back to the initial mode + expect(current_mode).toBe(initial_mode); + }); + + test("create_bbox_on_initial_crop_keybind should create a bbox covering the full image", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const keybind = await get_keybind_value(page, "Create BBox on Crop"); + + // Ensure we're in bbox mode + await page.click("a#md-btn--bbox"); + await page.waitForTimeout(100); + + // Get initial annotation count + const initial_count = await get_annotation_count(page); + expect(initial_count).toBe(0); + + // Press the keybind to create a full-image bbox + await press_keybind(page, keybind); + await page.waitForTimeout(200); + + // Verify an annotation was created + const new_count = await get_annotation_count(page); + expect(new_count).toBe(1); + + // Verify the bbox covers the full image (or initial crop) + const annotation = await get_annotation_by_index(page, 0); + expect(annotation.spatial_type).toBe("bbox"); + + // Get image dimensions to verify bbox size + const image_dimensions = await page.evaluate(() => { + return { + width: window.ulabel.config.image_width, + height: window.ulabel.config.image_height, + initial_crop: window.ulabel.config.initial_crop, + }; + }); + + // If there's an initial crop, the bbox should match it; otherwise, match the full image + if (image_dimensions.initial_crop) { + const crop = image_dimensions.initial_crop; + expect(annotation.spatial_payload).toEqual([ + [crop.left, crop.top], + [crop.left + crop.width, crop.top + crop.height], + ]); + } else { + expect(annotation.spatial_payload).toEqual([ + [0, 0], + [image_dimensions.width, image_dimensions.height], + ]); + } + }); + + test("fly_to_next and fly_to_prev should zoom in and cycle through annotations", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get the current keybind from the toolbox + const fly_to_keybind = await get_keybind_value(page, "Next Annotation"); + const fly_to_prev_keybind = await get_keybind_value(page, "Previous Annotation"); + + // Create multiple annotations at different non-overlapping positions + await draw_bbox(page, [100, 100], [180, 180]); + await page.waitForTimeout(100); + await draw_bbox(page, [200, 200], [280, 280]); + await page.waitForTimeout(100); + await draw_bbox(page, [300, 300], [380, 380]); + + // Verify we have 3 annotations + const count = await get_annotation_count(page); + expect(count).toBe(3); + + const initial_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + + // Press the keybind to fly to next annotation - should change the view + await press_keybind(page, fly_to_keybind); + await page.waitForTimeout(300); + + const first_anno_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + expect(first_anno_zoom).not.toBe(initial_zoom); + + // Press the keybind to fly to next annotation again + await press_keybind(page, fly_to_keybind); + await page.waitForTimeout(300); + + const second_anno_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + expect(second_anno_zoom).not.toBe(first_anno_zoom); + + // Presss the keybind to fly to previous annotation - should go back to first annotation + await press_keybind(page, fly_to_prev_keybind); + await page.waitForTimeout(300); + const back_to_first_zoom = await page.evaluate(() => { + return window.ulabel.state.zoom_val; + }); + expect(back_to_first_zoom).toBe(first_anno_zoom); + }); + + test("annotation_size keybinds should control annotation display size", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get all annotation size keybinds from the toolbox + const small_keybind = await get_keybind_value(page, "Size: Small"); + const large_keybind = await get_keybind_value(page, "Size: Large"); + const plus_keybind = await get_keybind_value(page, "Size: Increase"); + const minus_keybind = await get_keybind_value(page, "Size: Decrease"); + const vanish_keybind = await get_keybind_value(page, "Toggle Vanish"); + + // Create an annotation + await draw_bbox(page, [200, 200], [400, 400]); + + // Test small keybind - should set size to 1.5 + await press_keybind(page, small_keybind); + await page.waitForTimeout(200); + let annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(1.5); + + // Test large keybind - should set size to 5 + await press_keybind(page, large_keybind); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(5); + + // Test plus keybind - should increase by 0.5 + const size_before_plus = annotation.line_size; + await press_keybind(page, plus_keybind); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(size_before_plus + 0.5); + + // Test minus keybind - should decrease by 0.5 + const size_before_minus = annotation.line_size; + await press_keybind(page, minus_keybind); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(size_before_minus - 0.5); + + // Test vanish keybind - should set size to 0.01 (vanished) + const size_before_vanish = annotation.line_size; + await press_keybind(page, vanish_keybind); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(0.01); + + // Press vanish again to restore - should go back to previous size + await press_keybind(page, vanish_keybind); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(annotation.line_size).toBe(size_before_vanish); + }); + + test("brush mode keybinds should control brush state and size", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get brush-related keybinds from the toolbox + const toggle_brush_keybind = await get_keybind_value(page, "Toggle Brush"); + const toggle_erase_keybind = await get_keybind_value(page, "Toggle Erase"); + const increase_brush_keybind = await get_keybind_value(page, "Increase Brush Size"); + const decrease_brush_keybind = await get_keybind_value(page, "Decrease Brush Size"); + + // Switch to polygon mode (required for brush mode) + await page.click("a#md-btn--polygon"); + await page.waitForTimeout(200); + + // Helper to get brush state + const get_brush_state = async () => { + return await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return { + is_in_brush_mode: subtask.state.is_in_brush_mode, + is_in_erase_mode: subtask.state.is_in_erase_mode, + brush_size: window.ulabel.config.brush_size, + }; + }); + }; + + // Initial state - not in brush mode + let brush_state = await get_brush_state(); + expect(brush_state.is_in_brush_mode).toBe(false); + + // Test toggle brush keybind - should enter brush mode + await press_keybind(page, toggle_brush_keybind); + await page.waitForTimeout(200); + brush_state = await get_brush_state(); + expect(brush_state.is_in_brush_mode).toBe(true); + expect(brush_state.is_in_erase_mode).toBe(false); + + // Test increase brush size - should multiply by 1.1 + const initial_brush_size = brush_state.brush_size; + await press_keybind(page, increase_brush_keybind); + await page.waitForTimeout(200); + brush_state = await get_brush_state(); + expect(brush_state.brush_size).toBeCloseTo(initial_brush_size * 1.1, 5); + + // Test decrease brush size - should divide by 1.1 + const enlarged_brush_size = brush_state.brush_size; + await press_keybind(page, decrease_brush_keybind); + await page.waitForTimeout(200); + brush_state = await get_brush_state(); + expect(brush_state.brush_size).toBeCloseTo(enlarged_brush_size / 1.1, 5); + + // Test toggle erase keybind - should enter erase mode (also sets brush mode) + await press_keybind(page, toggle_erase_keybind); + await page.waitForTimeout(200); + brush_state = await get_brush_state(); + expect(brush_state.is_in_erase_mode).toBe(true); + + // Toggle erase again - should exit erase mode but stay in brush mode + await press_keybind(page, toggle_erase_keybind); + await page.waitForTimeout(200); + brush_state = await get_brush_state(); + expect(brush_state.is_in_erase_mode).toBe(false); + }); +}); diff --git a/tests/e2e/keybinds.spec.js b/tests/e2e/keybinds.spec.js index 86d0cb41..49435e6c 100644 --- a/tests/e2e/keybinds.spec.js +++ b/tests/e2e/keybinds.spec.js @@ -175,7 +175,6 @@ test.describe("Keybinds Toolbox Item", () => { // Get two different keybinds const deleteKeybindItem = page.locator(".keybind-item").filter({ hasText: "Delete Annotation" }); const deleteKeybindKey = deleteKeybindItem.locator(".keybind-key"); - const originalDeleteValue = await deleteKeybindKey.textContent(); const createPointItem = page.locator(".keybind-item").filter({ hasText: "Create Point" }); const createPointKey = createPointItem.locator(".keybind-key"); From 18b18a6831fff008f32981ff966781b1224e6f52 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 10:19:45 -0500 Subject: [PATCH 29/43] tests for non config keybinds --- tests/e2e/keybind-functionality.spec.js | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js index 6907eea6..a9e00066 100644 --- a/tests/e2e/keybind-functionality.spec.js +++ b/tests/e2e/keybind-functionality.spec.js @@ -492,4 +492,105 @@ test.describe("Keybind Functionality Tests", () => { brush_state = await get_brush_state(); expect(brush_state.is_in_erase_mode).toBe(false); }); + + test("undo and redo keybinds (ctrl+z and ctrl+shift+z) should undo and redo actions", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Get initial annotation count + let count = await get_annotation_count(page); + expect(count).toBe(0); + + // Create an annotation + await draw_bbox(page, [200, 200], [400, 400]); + await page.waitForTimeout(200); + + // Verify annotation was created + count = await get_annotation_count(page); + expect(count).toBe(1); + + // Press ctrl+z to undo + await press_keybind(page, "ctrl+z"); + await page.waitForTimeout(200); + + // Verify annotation was undone (removed from ordering) + count = await get_annotation_count(page); + expect(count).toBe(0); + + // Press ctrl+shift+z to redo + await press_keybind(page, "ctrl+shift+z"); + await page.waitForTimeout(200); + + // Verify annotation was restored + count = await get_annotation_count(page); + expect(count).toBe(1); + }); + + test("escape keybind should exit brush mode and erase mode", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Switch to polygon mode (required for brush/erase mode) + await page.click("a#md-btn--polygon"); + await page.waitForTimeout(200); + + // Test 1: Exit brush mode + // Enter brush mode + const toggle_brush_keybind = await get_keybind_value(page, "Toggle Brush"); + await press_keybind(page, toggle_brush_keybind); + await page.waitForTimeout(200); + + // Verify we're in brush mode + let brush_state = await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return { + is_in_brush_mode: subtask.state.is_in_brush_mode, + is_in_erase_mode: subtask.state.is_in_erase_mode, + }; + }); + expect(brush_state.is_in_brush_mode).toBe(true); + expect(brush_state.is_in_erase_mode).toBe(false); + + // Press escape to exit brush mode + await press_keybind(page, "escape"); + await page.waitForTimeout(200); + + // Verify we exited brush mode + brush_state = await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return { + is_in_brush_mode: subtask.state.is_in_brush_mode, + is_in_erase_mode: subtask.state.is_in_erase_mode, + }; + }); + expect(brush_state.is_in_brush_mode).toBe(false); + + // Test 2: Exit erase mode + // Enter erase mode + const toggle_erase_keybind = await get_keybind_value(page, "Toggle Erase"); + await press_keybind(page, toggle_erase_keybind); + await page.waitForTimeout(300); + + // Verify we're in erase mode + brush_state = await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return { + is_in_brush_mode: subtask.state.is_in_brush_mode, + is_in_erase_mode: subtask.state.is_in_erase_mode, + }; + }); + expect(brush_state.is_in_erase_mode).toBe(true); + + // Press escape to exit erase mode + await press_keybind(page, "escape"); + await page.waitForTimeout(200); + + // Verify we exited erase mode + brush_state = await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return { + is_in_brush_mode: subtask.state.is_in_brush_mode, + is_in_erase_mode: subtask.state.is_in_erase_mode, + }; + }); + expect(brush_state.is_in_erase_mode).toBe(false); + }); }); From 51c325dea6b6dd1908c1e93a16be826e9a8d0697 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 10:23:26 -0500 Subject: [PATCH 30/43] add case for cancel annotation --- tests/e2e/keybind-functionality.spec.js | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js index a9e00066..7aaf5325 100644 --- a/tests/e2e/keybind-functionality.spec.js +++ b/tests/e2e/keybind-functionality.spec.js @@ -525,14 +525,41 @@ test.describe("Keybind Functionality Tests", () => { expect(count).toBe(1); }); - test("escape keybind should exit brush mode and erase mode", async ({ page }) => { + test("escape keybind should cancel in-progress annotation, exit brush mode, and exit erase mode", async ({ page }) => { await wait_for_ulabel_init(page); - // Switch to polygon mode (required for brush/erase mode) + // Switch to polygon mode await page.click("a#md-btn--polygon"); await page.waitForTimeout(200); - // Test 1: Exit brush mode + // Test 1: Cancel annotation in progress + const canvas = page.locator("#annbox"); + + // Start drawing a polygon by clicking first point + await canvas.click({ position: { x: 200, y: 200 } }); + await page.waitForTimeout(100); + + // Verify annotation is in progress + let is_in_progress = await page.evaluate(() => { + return window.ulabel.get_current_subtask().state.is_in_progress; + }); + expect(is_in_progress).toBe(true); + + // Press escape to cancel + await press_keybind(page, "escape"); + await page.waitForTimeout(200); + + // Verify annotation was cancelled + is_in_progress = await page.evaluate(() => { + return window.ulabel.get_current_subtask().state.is_in_progress; + }); + expect(is_in_progress).toBe(false); + + // Verify annotation is now deprecated + const annotation = await get_annotation_by_index(page, 0); + expect(annotation.deprecated).toBe(true); + + // Test 2: Exit brush mode // Enter brush mode const toggle_brush_keybind = await get_keybind_value(page, "Toggle Brush"); await press_keybind(page, toggle_brush_keybind); @@ -563,7 +590,7 @@ test.describe("Keybind Functionality Tests", () => { }); expect(brush_state.is_in_brush_mode).toBe(false); - // Test 2: Exit erase mode + // Test 3: Exit erase mode // Enter erase mode const toggle_erase_keybind = await get_keybind_value(page, "Toggle Erase"); await press_keybind(page, toggle_erase_keybind); From 78b89d2c3e43283ba3be70a3877a7fc5fb432fe8 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 10:53:40 -0500 Subject: [PATCH 31/43] test class keybinds --- tests/e2e/keybind-functionality.spec.js | 90 ++++++++++++++++++++++++- tests/testing-utils/annotation_utils.js | 19 ++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js index 7aaf5325..77b1f8b8 100644 --- a/tests/e2e/keybind-functionality.spec.js +++ b/tests/e2e/keybind-functionality.spec.js @@ -1,7 +1,7 @@ // End-to-end tests for individual keybind functionality import { test, expect } from "./fixtures"; import { wait_for_ulabel_init } from "../testing-utils/init_utils"; -import { get_annotation_count, get_annotation_by_index } from "../testing-utils/annotation_utils"; +import { get_annotation_count, get_annotation_by_index, get_annotation_class_id } from "../testing-utils/annotation_utils"; import { draw_bbox } from "../testing-utils/drawing_utils"; import { get_current_subtask_key } from "../testing-utils/subtask_utils"; @@ -620,4 +620,92 @@ test.describe("Keybind Functionality Tests", () => { }); expect(brush_state.is_in_erase_mode).toBe(false); }); + + test("class keybinds should be settable, work, and reset to null/none", async ({ page }) => { + // Use multi-class demo which has classes with and without keybinds + await page.goto("demo/multi-class.html"); + await wait_for_ulabel_init(page); + + // Expand the keybinds toolbox + const keybinds_header = page.locator(".keybinds-header"); + await keybinds_header.click(); + await page.waitForTimeout(200); + + // Scroll down in the keybinds list to find class keybinds + const keybinds_list = page.locator(".keybinds-list"); + await keybinds_list.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + await page.waitForTimeout(200); + + // Find the "Truck" class keybind item (starts with no keybind) + const truck_keybind_item = page.locator(".keybind-item").filter({ hasText: "Truck" }); + await expect(truck_keybind_item).toBeVisible(); + + // Verify initial keybind is "none" or empty + let truck_key = truck_keybind_item.locator(".keybind-key"); + let initial_keybind = await truck_key.textContent(); + expect(initial_keybind.trim().toLowerCase()).toMatch(/none|^$/); + + // Click to set a new keybind + await truck_key.click(); + await page.waitForTimeout(100); + + // Press a key to set the keybind + await page.keyboard.press("3"); + await page.waitForTimeout(200); + + // Verify the keybind was set + let new_keybind = await truck_key.textContent(); + expect(new_keybind.trim()).toBe("3"); + + // Test that the keybind works - create an annotation and press the keybind + await page.click("a#md-btn--bbox"); + await page.waitForTimeout(200); + + // Draw a bbox + await draw_bbox(page, [200, 200], [400, 400]); + await page.waitForTimeout(200); + + // Hover over the created annotation to select it + await page.mouse.move(300, 300); + await page.waitForTimeout(200); + + // Get the annotation and verify initial class + let annotation = await get_annotation_by_index(page, 0); + expect(get_annotation_class_id(annotation)).toBe(10); // Sedan + + // Press the keybind to select the Truck class + await press_keybind(page, "3"); + await page.waitForTimeout(200); + + // Verify the active class changed to Truck (id: 12) + annotation = await get_annotation_by_index(page, 0); + expect(get_annotation_class_id(annotation)).toBe(12); + + // Hit the reset button to clear the Truck keybind + const reset_button = truck_keybind_item.locator(".keybind-reset-btn"); + await reset_button.click(); + await page.waitForTimeout(200); + + // Verify keybind was reset to none + const reset_keybind = await truck_key.textContent(); + expect(reset_keybind.trim().toLowerCase()).toMatch(/none|^$/); + + // Hover over the annotation and press "1" to select Sedan + await page.mouse.move(300, 300); + await page.waitForTimeout(200); + await page.keyboard.press("1"); + await page.waitForTimeout(200); + + // Verify active class is now Sedan (id: 10) + annotation = await get_annotation_by_index(page, 0); + expect(get_annotation_class_id(annotation)).toBe(10); + + // Verify the keybind no longer works - press "3" and check active class doesn't change to Truck + await page.keyboard.press("3"); + await page.waitForTimeout(200); + annotation = await get_annotation_by_index(page, 0); + expect(get_annotation_class_id(annotation)).toBe(10); // Still Sedan + }); }); diff --git a/tests/testing-utils/annotation_utils.js b/tests/testing-utils/annotation_utils.js index 0524c8a6..7f1d93c3 100644 --- a/tests/testing-utils/annotation_utils.js +++ b/tests/testing-utils/annotation_utils.js @@ -42,3 +42,22 @@ export async function get_all_annotations(page) { ); }); } + +/** + * Get the class ID of an annotation based on highest confidence. + * @param {Object} annotation - The annotation object + * @returns {number|null} The class ID with highest confidence or null + */ +export function get_annotation_class_id(annotation) { + // Classification with highest confidence + if ( + annotation.classification_payloads && + annotation.classification_payloads.length > 0 + ) { + const sorted = annotation.classification_payloads.sort( + (a, b) => b.confidence - a.confidence, + ); + return sorted[0].class_id; + } + return null; +} From 557dd8e6ebe7f994ae0280c2b1858c07f00fd98e Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 11:01:01 -0500 Subject: [PATCH 32/43] Bump version and update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ package.json | 2 +- src/version.js | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 309a3503..4cdce079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented here. ## [unreleased] +## [0.22.0] - Oct 30th 2025 +- Add collapsible toolbox with arrow button at top + - Toolbox collapse state persists in browser + - Annotation canvas expands to fill space when toolbox is collapsed +- Add `Keybinds` toolbox item for viewing and customizing keybinds + - Display all configurable keybinds with labels and descriptions + - Edit keybinds by clicking and pressing new key combination + - Support for modifier key chords (shift, ctrl, alt, meta) + - Collision detection with red highlighting for duplicate keybinds + - Reset individual keybinds or all keybinds to defaults + - Visual indicator (yellow highlight) for user-customized keybinds + - User keybind settings persist in browser +- Rename `create_bbox_on_initial_crop` to `create_bbox_on_initial_crop_keybind` for consistency +- Split `change_zoom_keybind` into two separate keybinds: + - `reset_zoom_keybind` (default: `r`) - Reset zoom to fit image + - `show_full_image_keybind` (default: `shift+r`) - Zoom to show full image +- Store collapse/expand state for Keybinds, Annotation List, and Image Filters toolbox items +- Add comprehensive e2e tests for keybind functionality and keybind toolbox item + ## [0.21.0] - Oct 27th 2025 - Add toast notification that shows on `fly_to` calls and shows annotation position in the ordering (e.g., "3 / 10") - Add `AnnotationList` toolbox item for managing and navigating annotations diff --git a/package.json b/package.json index 0a7defa2..41acbd10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.21.0", + "version": "0.22.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "exports": { diff --git a/src/version.js b/src/version.js index 1e7ec81f..7026927c 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.21.0"; +export const ULABEL_VERSION = "0.22.0"; From dc90f036e64c2456b64e952406614cce1ed9e890 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 30 Oct 2025 11:19:14 -0500 Subject: [PATCH 33/43] add more other keybinds --- api_spec.md | 2 +- src/toolbox_items/keybinds.ts | 39 ++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/api_spec.md b/api_spec.md index a3499c87..1a59b89b 100644 --- a/api_spec.md +++ b/api_spec.md @@ -8,7 +8,7 @@ This should eventually be replaced with a more comprehensive approach to documen - `ctrl+shift+z` or `cmd+shift+z`: Redo - `scroll`: Zoom -- up for in, down for out - `ctrl+scroll` or `shift+scroll` or `cmd+scroll`: Change frame -- down for next, up for previous -- `scrollclick+drag` or `ctrl+drag`: Pan +- `scrollclick+drag`: Pan - Hold `shift` when closing a polygon to continue annotating a new region or hole. - Hold `shift` when moving the cursor inside a polygon to begin annotating a new region or hole. - Press `Escape` or `crtl+z` to cancel the start of a new region or hole. diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 2bae8339..97fe8619 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -187,7 +187,7 @@ export class KeybindsToolboxItem extends ToolboxItem { #toolbox .keybind-key { font-family: monospace; font-weight: bold; - font-size: 0.9rem; + font-size: 0.75rem; padding: 0.3rem 0.6rem; background-color: rgba(0, 0, 0, 0.1); border-radius: 4px; @@ -467,6 +467,43 @@ export class KeybindsToolboxItem extends ToolboxItem { configurable: false, }); + // Mouse + Modifier combinations + keybinds.push({ + key: "Middle Click", + label: "Pan", + description: "Pan the image by dragging with middle mouse button", + configurable: false, + }); + + keybinds.push({ + key: "Shift + Click Drag", + label: "Zoom Drag", + description: "Zoom in/out by holding Shift and dragging with left mouse button up/down", + configurable: false, + }); + + // Scroll combinations + keybinds.push({ + key: "Scroll", + label: "Zoom", + description: "Zoom in/out using mouse scroll wheel", + configurable: false, + }); + + keybinds.push({ + key: "Alt + Scroll", + label: "Brush Size", + description: "Change brush size using Alt + scroll wheel (when in brush mode)", + configurable: false, + }); + + keybinds.push({ + key: "Ctrl/Shift + Scroll", + label: "Frame", + description: "Navigate between frames using Ctrl or Shift + scroll wheel (multi-frame images)", + configurable: false, + }); + return keybinds; } From f8c7444e1a1985bf7ea09b0553aefb1cef4e29fe Mon Sep 17 00:00:00 2001 From: Trevor Burgoyne <82477095+TrevorBurgoyne@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:30:01 -0500 Subject: [PATCH 34/43] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/configuration.ts | 1 - tests/e2e/keybind-functionality.spec.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index a1146b5f..3a3ad05d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -168,7 +168,6 @@ export class Configuration { AllowedToolboxItem.Keybinds, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, - AllowedToolboxItem.Keybinds, AllowedToolboxItem.Brush, AllowedToolboxItem.ImageFilters, AllowedToolboxItem.ZoomPan, diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js index 77b1f8b8..0bfd210e 100644 --- a/tests/e2e/keybind-functionality.spec.js +++ b/tests/e2e/keybind-functionality.spec.js @@ -368,7 +368,7 @@ test.describe("Keybind Functionality Tests", () => { }); expect(second_anno_zoom).not.toBe(first_anno_zoom); - // Presss the keybind to fly to previous annotation - should go back to first annotation + // Press the keybind to fly to previous annotation - should go back to first annotation await press_keybind(page, fly_to_prev_keybind); await page.waitForTimeout(300); const back_to_first_zoom = await page.evaluate(() => { From 08211684c26b92e6195a99c4af2813047f81d5dc Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Fri, 31 Oct 2025 10:06:27 -0500 Subject: [PATCH 35/43] minor tweaks to keybind descriptions --- src/toolbox_items/keybinds.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index 97fe8619..d922acc4 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -343,7 +343,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_brush_mode_keybind, label: "Toggle Brush", - description: "Toggle brush mode for polygon/contour annotation", + description: "Toggle brush mode for polygon annotation", configurable: true, config_key: "toggle_brush_mode_keybind", }); @@ -351,7 +351,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds.push({ key: config.toggle_erase_mode_keybind, label: "Toggle Erase", - description: "Toggle erase mode in brush", + description: "Toggle erase mode in for polygon annotation", configurable: true, config_key: "toggle_erase_mode_keybind", }); From 40ed0fc7f919a9df74fdeb532d4d40c9431c503d Mon Sep 17 00:00:00 2001 From: Trevor Burgoyne <82477095+TrevorBurgoyne@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:35:56 -0500 Subject: [PATCH 36/43] Update src/toolbox_items/keybinds.ts Co-authored-by: Joshua Dean --- src/toolbox_items/keybinds.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index d922acc4..4d0cebe6 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -768,7 +768,7 @@ export class KeybindsToolboxItem extends ToolboxItem { keybinds_html += `
- Other + Other (Non-Configurable) ${toggle_icon}
From 50d3254a0c8446bd95f06a70c7493c97bad41b8e Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 12 Nov 2025 11:41:41 -0500 Subject: [PATCH 37/43] change toggle target to header --- src/toolbox_items/annotation_list.ts | 4 ++-- src/toolbox_items/image_filters.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/toolbox_items/annotation_list.ts b/src/toolbox_items/annotation_list.ts index 7f7249a5..b458ddfb 100644 --- a/src/toolbox_items/annotation_list.ts +++ b/src/toolbox_items/annotation_list.ts @@ -262,8 +262,8 @@ export class AnnotationListToolboxItem extends ToolboxItem { * Initialize event listeners for this toolbox item */ private add_event_listeners() { - // Toggle button to show/hide annotation list - $(document).on("click.ulabel", "#annotation-list-toggle", () => { + // Toggle button to show/hide annotation list (click anywhere on header) + $(document).on("click.ulabel", ".annotation-list-header", () => { this.is_collapsed = !this.is_collapsed; set_local_storage_item("ulabel_annotation_list_collapsed", this.is_collapsed ? "true" : "false"); this.update_list(); diff --git a/src/toolbox_items/image_filters.ts b/src/toolbox_items/image_filters.ts index e53209f8..6c159bdd 100644 --- a/src/toolbox_items/image_filters.ts +++ b/src/toolbox_items/image_filters.ts @@ -266,8 +266,8 @@ export class ImageFiltersToolboxItem extends ToolboxItem { * Initialize event listeners for this toolbox item */ public init_listeners() { - // Toggle button to show/hide filter controls - $(document).on("click.ulabel", "#image-filters-toggle", () => { + // Toggle button to show/hide filter controls (click anywhere on header) + $(document).on("click.ulabel", ".image-filters-header", () => { const content = document.querySelector("#image-filters-content"); const toggle_btn = document.querySelector("#image-filters-toggle"); From cda92120e911cd824995a8696bba507a1c8abf31 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 12 Nov 2025 09:14:30 -0800 Subject: [PATCH 38/43] full collapse toolbox, button floats --- src/initializer.ts | 4 +++- src/listeners.ts | 5 ++++- src/toolbox.ts | 50 +++++++++++++++++++++++----------------------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/initializer.ts b/src/initializer.ts index 810651ca..a72b8fd2 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -202,8 +202,10 @@ export async function ulabel_init( const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed"); if (is_collapsed === "true") { const toolbox = $("#" + ulabel.config["toolbox_id"]); - const btn = toolbox.find(".toolbox-collapse-btn"); + const container = $(".full_ulabel_container_"); + const btn = $(".toolbox-collapse-btn"); toolbox.addClass("collapsed"); + container.addClass("toolbox-collapsed"); btn.text("▶"); btn.attr("title", "Expand toolbox"); } diff --git a/src/listeners.ts b/src/listeners.ts index b5f74910..7a8930d2 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -615,18 +615,21 @@ export function create_ulabel_listeners( // Button to collapse/expand toolbox $(document).on( "click" + ULABEL_NAMESPACE, - "#" + ulabel.config["toolbox_id"] + " .toolbox-collapse-btn", + ".toolbox-collapse-btn", (e) => { const toolbox = $("#" + ulabel.config["toolbox_id"]); + const container = $(".full_ulabel_container_"); const btn = $(e.currentTarget); if (toolbox.hasClass("collapsed")) { toolbox.removeClass("collapsed"); + container.removeClass("toolbox-collapsed"); btn.text("◀"); btn.attr("title", "Collapse toolbox"); set_local_storage_item("ulabel_toolbox_collapsed", "false"); } else { toolbox.addClass("collapsed"); + container.addClass("toolbox-collapsed"); btn.text("▶"); btn.attr("title", "Expand toolbox"); set_local_storage_item("ulabel_toolbox_collapsed", "true"); diff --git a/src/toolbox.ts b/src/toolbox.ts index be6ffac7..ca6ff68d 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -108,49 +108,49 @@ export class Toolbox { background-color: white; overflow-y: hidden; position: absolute; - top: 0; + top: 35px; right: 0; - transition: transform 300ms ease-in-out; } #toolbox.collapsed { - transform: translateX(calc(100% - 40px)); + display: none; } .annbox_cls { - transition: width 300ms ease-in-out; + width: calc(100% - 320px); } - #toolbox.collapsed ~ * .annbox_cls, - .full_ulabel_container_:has(#toolbox.collapsed) .annbox_cls { - width: calc(100% - 40px) !important; - } - - .toolbox-header-container { - display: flex; - align-items: flex-start; - } - - .ulabel-night .toolbox-header-container { - background-color: rgb(0, 60, 95); + .full_ulabel_container_.toolbox-collapsed .annbox_cls { + width: 100% !important; } .toolbox-collapse-btn { - flex: 1; - flex-shrink: 0; + position: fixed; + top: 10px; + right: 10px; + z-index: 1000; border-radius: 5px 0 0 5px; color: white; - font-size: 1.2rem; - transition: all 300ms ease-in-out; + font-size: 1.5rem; padding: 5px 10px; + width: 40px; + background-color: rgba(0, 128, 255, 0.7); + border: 1px solid rgba(128, 128, 128, 0.5); + cursor: pointer; + display: block; } .toolbox-collapse-btn:hover { background-color: rgba(0, 128, 255, 0.9); } - #toolbox.collapsed .toolbox-collapse-btn { - font-size: 1.2rem; + .toolbox-header-container { + display: flex; + align-items: flex-start; + } + + .ulabel-night .toolbox-header-container { + background-color: rgb(0, 60, 95); } .ulabel-night #toolbox { @@ -252,12 +252,12 @@ export class Toolbox { ${images}
+
-
-

ULabel v${ULABEL_VERSION}

+

ULabel v${ULABEL_VERSION}

+
From 079edc90f8194727a73c5b65862db4f67d29ea3c Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 12 Nov 2025 12:26:19 -0500 Subject: [PATCH 39/43] round out the button --- src/toolbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolbox.ts b/src/toolbox.ts index ca6ff68d..c389103f 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -129,7 +129,7 @@ export class Toolbox { top: 10px; right: 10px; z-index: 1000; - border-radius: 5px 0 0 5px; + border-radius: 5px; color: white; font-size: 1.5rem; padding: 5px 10px; From 750aa80d9a5e8ea0a08bcff110cf73c60a9e20f7 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 12 Nov 2025 12:59:18 -0500 Subject: [PATCH 40/43] fix toolbox collapse css --- src/blobs.js | 2 +- src/listeners.ts | 4 ++-- src/toolbox.ts | 30 +++++++++++++++++------------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/blobs.js b/src/blobs.js index cec7289c..9a24d7e9 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -2037,7 +2037,7 @@ div#${prntid}.ulabel-night div.toolbox-name-header h1 span.version-number { color: rgb(190, 190, 190); } div#${prntid} div.night-button-cont { - text-align: right; + text-align: left; display: inline-block; vertical-align: middle; position: relative; diff --git a/src/listeners.ts b/src/listeners.ts index 7a8930d2..f7112677 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -624,13 +624,13 @@ export function create_ulabel_listeners( if (toolbox.hasClass("collapsed")) { toolbox.removeClass("collapsed"); container.removeClass("toolbox-collapsed"); - btn.text("◀"); + btn.text("▶"); btn.attr("title", "Collapse toolbox"); set_local_storage_item("ulabel_toolbox_collapsed", "false"); } else { toolbox.addClass("collapsed"); container.addClass("toolbox-collapsed"); - btn.text("▶"); + btn.text("◀"); btn.attr("title", "Expand toolbox"); set_local_storage_item("ulabel_toolbox_collapsed", "true"); } diff --git a/src/toolbox.ts b/src/toolbox.ts index c389103f..69af83a1 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -108,7 +108,7 @@ export class Toolbox { background-color: white; overflow-y: hidden; position: absolute; - top: 35px; + top: 0; right: 0; } @@ -126,27 +126,31 @@ export class Toolbox { .toolbox-collapse-btn { position: fixed; - top: 10px; - right: 10px; + top: 0; + right: 0; z-index: 1000; border-radius: 5px; color: white; - font-size: 1.5rem; - padding: 5px 10px; - width: 40px; - background-color: rgba(0, 128, 255, 0.7); - border: 1px solid rgba(128, 128, 128, 0.5); + font-size: 1.2rem; + width: 37px; + height: 37px; + padding: 0; + background-color: rgba(255, 166, 0, 0.739); + border: 1px solid rgba(0, 128, 128, 0.5); cursor: pointer; - display: block; + display: flex; + align-items: center; + justify-content: center; } .toolbox-collapse-btn:hover { - background-color: rgba(0, 128, 255, 0.9); + background-color: rgba(255, 166, 0, 0.9); } .toolbox-header-container { display: flex; align-items: flex-start; + height: 40px; } .ulabel-night .toolbox-header-container { @@ -252,12 +256,12 @@ export class Toolbox { ${images}
- +
-

ULabel v${ULABEL_VERSION}

-
+

ULabel v${ULABEL_VERSION}

From 2391f3683f33472b954dc1a9898ba1b84e7a4611 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 12 Nov 2025 13:07:44 -0500 Subject: [PATCH 41/43] prevent scroll into view on toolbox collapse --- src/toolbox_items/annotation_list.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/toolbox_items/annotation_list.ts b/src/toolbox_items/annotation_list.ts index b458ddfb..4c1ba4bc 100644 --- a/src/toolbox_items/annotation_list.ts +++ b/src/toolbox_items/annotation_list.ts @@ -657,8 +657,11 @@ export class AnnotationListToolboxItem extends ToolboxItem { const list_item = $(`.annotation-list-item[data-annotation-id="${edit_candidate.annid}"]`); if (list_item.length > 0) { list_item.addClass("highlighted"); - // Optionally scroll the item into view - list_item[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + // Only scroll into view if toolbox is not collapsed + const toolbox = $("#" + this.ulabel.config["toolbox_id"]); + if (!toolbox.hasClass("collapsed")) { + list_item[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } } } } @@ -675,8 +678,11 @@ export class AnnotationListToolboxItem extends ToolboxItem { const list_item = $(`.annotation-list-item[data-annotation-id="${annotation_id}"]`); if (list_item.length > 0) { list_item.addClass("highlighted"); - // Scroll the item into view - list_item[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + // Only scroll into view if toolbox is not collapsed + const toolbox = $("#" + this.ulabel.config["toolbox_id"]); + if (!toolbox.hasClass("collapsed")) { + list_item[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } } } } From 8825931485be9ff4fa8e5c03e5bbbc8c8f7ec9f0 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Mon, 17 Nov 2025 11:04:49 -0600 Subject: [PATCH 42/43] update api spec --- api_spec.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api_spec.md b/api_spec.md index 1a59b89b..3e966b02 100644 --- a/api_spec.md +++ b/api_spec.md @@ -347,7 +347,8 @@ enum AllowedToolboxItem { FilterDistance, // 8 Brush, // 9 ImageFilters, // 10 - AnnotationList // 11 + AnnotationList, // 11 + Keybinds, // 12 } ``` You can access the AllowedToolboxItem enum by calling the static method: From ef1ec2143f797320bbc1a00e0e7aeee89215a0cf Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Mon, 17 Nov 2025 11:05:25 -0600 Subject: [PATCH 43/43] changelog consistency --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945c408c..16198b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented here. ## [unreleased] -## [0.22.0] - Oct 30th 2025 +## [0.22.0] - Oct 30th, 2025 - Add collapsible toolbox with arrow button at top - Toolbox collapse state persists in browser - Annotation canvas expands to fill space when toolbox is collapsed