diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7a01a701..a2a7f7ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,9 +6,14 @@ - Do not add new dependencies unless given explicit permission. - Do not modify the `package.json` or `package-lock.json` files unless instructed. - ## 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 8866fe7c..1cdb8238 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,36 +1,77 @@ ## 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 + - [x] Move arrow to top of toolbox (instead of middle) + - [x] Make annbox expand when toolbox is collapsed + - [x] Make collapsed button visible +- [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 + - [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 + - [x] Implement editing for configurable keybinds + - [x] Test functionality +- [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) + - [x] Test chord functionality +- [x] Store collapse/expand for applicable toolbox items + - [x] Keybinds + - [x] Annotation List + - [x] Image Filters +- [x] Make all keybinds configurable +- [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" +- [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 + - [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 + - [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 +- [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 +- [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 + - [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) -### 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/CHANGELOG.md b/CHANGELOG.md index 853af099..16198b81 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/api_spec.md b/api_spec.md index e7ada40a..3e966b02 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. @@ -50,16 +50,10 @@ 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, + reset_zoom_keybind: string, + show_full_image_keybind: string, create_point_annotation_keybind: string, default_annotation_size: number, delete_annotation_keybind: string, @@ -67,13 +61,18 @@ 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, decrease_brush_size_keybind: string, fly_to_next_annotation_keybind: string, - fly_to_previous_annotation_keybind: string | null, + 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 }) @@ -348,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: @@ -356,18 +356,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 @@ -437,8 +425,11 @@ 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 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. @@ -461,7 +452,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` @@ -480,7 +471,22 @@ 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`). + +### `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/demo/multi-class.html b/demo/multi-class.html index 571c5bca..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", @@ -119,6 +119,7 @@ "initial_line_size": 2, "toolbox_order": [ AllowedToolboxItem.SubmitButtons, + AllowedToolboxItem.Keybinds, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, AllowedToolboxItem.ImageFilters, @@ -131,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/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/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/blobs.js b/src/blobs.js index d8304f53..9a24d7e9 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); @@ -2036,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/configuration.ts b/src/configuration.ts index e3a5ad71..3a3ad05d 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 */ @@ -70,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; @@ -135,10 +160,12 @@ 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.Keybinds, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.AnnotationList, AllowedToolboxItem.Brush, @@ -152,14 +179,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, @@ -171,7 +190,9 @@ 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 show_full_image_keybind: string = "shift+r"; public create_point_annotation_keybind: string = "c"; @@ -187,7 +208,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"; @@ -197,10 +218,19 @@ export class Configuration { public decrease_brush_size_keybind: string = "["; - public fly_to_next_annotation_keybind: string = "Tab"; + 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"; - // 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/index.js b/src/index.js index 7389eaa4..0e2dcb6a 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, @@ -939,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 3d4bbd59..a72b8fd2 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -7,12 +7,14 @@ // 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"; import { ULabelLoader } from "./loader"; import { ULabelSubtask } from "./subtask"; import { ULabelAnnotation } from "./annotation"; +import { get_local_storage_item } from "./utilities"; /** * Make canvases for each subtask @@ -58,6 +60,80 @@ function make_image_canvases( } } +/** + * Store original keybinds before customization + * + * @param ulabel ULabel instance to store original keybinds for + */ +function store_original_keybinds(ulabel: ULabel) { + // Store original config keybinds (from constructor, before localStorage) + const original_config_keybinds: { [config_key: string]: string } = {}; + + for (const key of Configuration.KEYBIND_CONFIG_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_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) { + original_class_keybinds[class_def.id] = class_def?.keybind; + } + } + } + ulabel.state["original_class_keybinds"] = original_class_keybinds; +} + +/** + * Restore custom keybinds from localStorage + * + * @param ulabel ULabel instance to restore keybinds for + */ +function restore_custom_keybinds(ulabel: 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"); + 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. @@ -72,6 +148,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]; @@ -119,6 +198,18 @@ 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 container = $(".full_ulabel_container_"); + const btn = $(".toolbox-collapse-btn"); + toolbox.addClass("collapsed"); + container.addClass("toolbox-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..f7112677 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -9,9 +9,46 @@ 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"; +/** + * 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 || typeof keybind !== "string") { + 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. * @@ -22,104 +59,123 @@ 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 - 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_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 + 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; + } + + // Reset zoom to initial crop + if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind)) { + ulabel.show_initial_crop(); + return; + } + + // Show full image + if (event_matches_keybind(keypress_event, ulabel.config.show_full_image_keybind)) { + 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; + } } } @@ -203,6 +259,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 = ( @@ -219,41 +280,39 @@ 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)) { + keydown_event.preventDefault(); + ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom); + return false; + } + + // Handle fly to previous annotation + 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; } } @@ -385,12 +444,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(); } }, @@ -489,9 +548,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)) { @@ -537,7 +596,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 +612,31 @@ export function create_ulabel_listeners( }, ); + // Button to collapse/expand toolbox + $(document).on( + "click" + ULABEL_NAMESPACE, + ".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"); + } + }, + ); + // Keyboard only events $(document).on( "keydown" + ULABEL_NAMESPACE, diff --git a/src/toolbox.ts b/src/toolbox.ts index 457a2149..69af83a1 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -112,6 +112,51 @@ export class Toolbox { right: 0; } + #toolbox.collapsed { + display: none; + } + + .annbox_cls { + width: calc(100% - 320px); + } + + .full_ulabel_container_.toolbox-collapsed .annbox_cls { + width: 100% !important; + } + + .toolbox-collapse-btn { + position: fixed; + top: 0; + right: 0; + z-index: 1000; + border-radius: 5px; + color: white; + 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: flex; + align-items: center; + justify-content: center; + } + + .toolbox-collapse-btn:hover { + background-color: rgba(255, 166, 0, 0.9); + } + + .toolbox-header-container { + display: flex; + align-items: flex-start; + height: 40px; + } + + .ulabel-night .toolbox-header-container { + background-color: rgb(0, 60, 95); + } + .ulabel-night #toolbox { color: white; } @@ -211,15 +256,18 @@ export class Toolbox { ${images} +
-
-

ULabel v${ULABEL_VERSION}

- -
-
-
-
+
+
+

ULabel v${ULABEL_VERSION}

@@ -901,10 +949,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) { document.getElementById("recenter-button").click(); } - if (e.key == this.ulabel.config.change_zoom_keybind.toUpperCase()) { + if (e.key == this.ulabel.config.show_full_image_keybind) { document.getElementById("recenter-whole-image-button").click(); } }); @@ -1156,7 +1204,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) { @@ -1164,9 +1211,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. @@ -1287,22 +1331,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/annotation_list.ts b/src/toolbox_items/annotation_list.ts index 86ae716e..4c1ba4bc 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, @@ -261,9 +262,10 @@ 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(); }); @@ -602,10 +604,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 */ @@ -640,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" }); + } } } } @@ -658,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" }); + } } } } diff --git a/src/toolbox_items/image_filters.ts b/src/toolbox_items/image_filters.ts index 0a481bf8..6c159bdd 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,23 +234,48 @@ 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 */ 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"); 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 new file mode 100644 index 00000000..4d0cebe6 --- /dev/null +++ b/src/toolbox_items/keybinds.ts @@ -0,0 +1,1158 @@ +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"; +import { log_message, LogLevel } from "../error_logging"; + +interface KeybindInfo { + key: string; + label: string; + description: string; + configurable: boolean; + config_key?: string; + class_id?: number; // For class keybinds +} + +/** + * 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-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; + 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; + gap: 0.5rem; + } + + .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-description { + flex: 0 1 auto; + margin-right: 0.75rem; + color: #333; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + 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.5; + 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; + } + + #toolbox .keybind-key { + font-family: monospace; + font-weight: bold; + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + min-width: 30px; + text-align: center; + white-space: nowrap; + flex-shrink: 0; + } + + .ulabel-night #toolbox .keybind-key { + 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; + } + + #toolbox .keybind-key.keybind-editable { + cursor: pointer; + border: 1px solid transparent; + } + + #toolbox .keybind-key.keybind-editable:hover { + background-color: rgba(0, 128, 255, 0.2); + border-color: rgba(0, 128, 255, 0.5); + } + + #toolbox .keybind-key.editing { + outline: 2px solid rgba(0, 128, 255, 0.7); + } + + #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); + 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; + } + `; + + 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.reset_zoom_keybind, + label: "Reset Zoom", + description: "Change zoom mode (hold: drag zoom, release: single-click zoom)", + configurable: true, + 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", + 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, + label: "Delete Annotation", + description: "Delete (deprecate) the active annotation", + configurable: true, + config_key: "delete_annotation_keybind", + }); + + keybinds.push({ + key: config.switch_subtask_keybind, + label: "Switch Subtask", + description: "Switch to the next subtask", + configurable: true, + config_key: "switch_subtask_keybind", + }); + + keybinds.push({ + key: config.toggle_annotation_mode_keybind, + label: "Toggle Annotation Mode", + description: "Toggle between annotation modes", + configurable: true, + config_key: "toggle_annotation_mode_keybind", + }); + + keybinds.push({ + 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_keybind", + }); + + keybinds.push({ + key: config.toggle_brush_mode_keybind, + label: "Toggle Brush", + description: "Toggle brush mode for polygon annotation", + configurable: true, + config_key: "toggle_brush_mode_keybind", + }); + + keybinds.push({ + key: config.toggle_erase_mode_keybind, + label: "Toggle Erase", + description: "Toggle erase mode in for polygon annotation", + configurable: true, + config_key: "toggle_erase_mode_keybind", + }); + + keybinds.push({ + key: config.increase_brush_size_keybind, + label: "Increase Brush Size", + description: "Increase brush size", + configurable: true, + config_key: "increase_brush_size_keybind", + }); + + keybinds.push({ + key: config.decrease_brush_size_keybind, + label: "Decrease Brush Size", + description: "Decrease brush size", + configurable: true, + config_key: "decrease_brush_size_keybind", + }); + + 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", + }); + + 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.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(); + if (current_subtask && current_subtask.class_defs) { + for (const class_def of current_subtask.class_defs) { + // 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, + }); + } + } + + // 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, + }); + + // 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; + } + + /** + * Check if a key has collisions with other keybinds + */ + private has_collision(key: string, all_keybinds: KeybindInfo[]): boolean { + // 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; + } + + /** + * 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)); + } + + /** + * Get the default value for a keybind (from constructor, not hardcoded defaults) + */ + private get_default_keybind(config_key: string): 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; + } + + /** + * 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) { + log_message(`Failed to update custom keybinds: ${e}`, LogLevel.ERROR, true); + } + } + } + + /** + * Get original class keybinds (before customization) + */ + private get_original_class_keybinds(): { [class_id: number]: string } { + // 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 { + const custom_keybinds = JSON.parse(stored); + return config_key in custom_keybinds; + } catch { + return false; + } + } + return false; + } + + /** + * Check if a class keybind is customized (different from default) + */ + private is_class_keybind_customized(class_id: number): boolean { + const stored = get_local_storage_item("ulabel_custom_class_keybinds"); + if (stored) { + try { + const custom_class_keybinds = JSON.parse(stored); + return class_id in custom_class_keybinds; + } catch { + return false; + } + } + return false; + } + + /** + * Reset a class keybind to its default value + */ + 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) { + const original_class_keybinds = this.get_original_class_keybinds(); + class_def.keybind = original_class_keybinds[class_id]; + } + + // 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) { + log_message(`Failed to update custom class keybinds: ${e}`, LogLevel.ERROR, true); + } + } + } + + /** + * Reset all keybinds to their default values + */ + private reset_all_keybinds_to_default(): void { + // Reset all regular keybinds + for (const key of Configuration.KEYBIND_CONFIG_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 + */ + 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 && kb.class_id === undefined); + 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); + 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} +
+ ${reset_button} + ${display_key} +
+
+ `; + } + keybinds_html += "
"; + } + + // 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); + 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 : "none"; + const reset_button = is_customized ? `` : ""; + + keybinds_html += ` +
+ ${keybind.label} +
+ ${reset_button} + ${display_key} +
+
+ `; + } + keybinds_html += "
"; + } + + // Other keybinds + if (other.length > 0) { + const toggle_icon = other_collapsed ? "▶" : "▼"; + const section_class = other_collapsed ? " collapsed" : ""; + + keybinds_html += ` +
+ Other (Non-Configurable) + ${toggle_icon} +
+
+ `; + 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_html += "
"; + } + + return keybinds_html; + } + + /** + * Generate HTML for the toolbox item + */ + public get_html(): string { + const keybinds_html = this.generate_keybinds_list_html(); + + 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(); + 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 + + // 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("▶"); + } + } + } + + /** + * 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 + */ + 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("▼"); + set_local_storage_item("ulabel_keybinds_collapsed", "true"); + } else { + content.addClass("expanded"); + toggle_btn.text("▲"); + set_local_storage_item("ulabel_keybinds_collapsed", "false"); + } + }); + + // 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"); + } + }); + + // 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; + + if (class_id !== undefined) { + // Reset class keybind + this.reset_class_keybind_to_default(class_id); + } 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(); + 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")) { + return; + } + + // Remove editing class from any other key + $(".keybind-key.editing").removeClass("editing"); + + // 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 + 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(); + + // Handle Escape to cancel + if (keyEvent.key === "Escape") { + target.removeClass("editing"); + target.text(original_value); + $(document).off("keydown.keybind-edit"); + this.ulabel.state.is_editing_keybind = false; + return; + } + + // 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 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; + + // 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; + + // 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 + target.removeClass("editing"); + target.text(new_key); + + // Refresh the entire keybinds list to update collision detection + this.refresh_keybinds_display(); + + // Remove the keydown handler and clear editing flag + $(document).off("keydown.keybind-edit"); + this.ulabel.state.is_editing_keybind = false; + }; + + // 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; + const class_id = editing_key.data("class-id") as number; + editing_key.removeClass("editing"); + + // 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; + } + } + }); + } + + /** + * Refresh the keybinds display to show updated keys and collision detection + */ + public refresh_keybinds_display(): void { + const keybinds_list = $(".keybinds-list"); + if (keybinds_list.length === 0) { + return; + } + + const keybinds_html = this.generate_keybinds_list_html(); + keybinds_list.html(keybinds_html); + } +} 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"; diff --git a/tests/e2e/keybind-functionality.spec.js b/tests/e2e/keybind-functionality.spec.js new file mode 100644 index 00000000..0bfd210e --- /dev/null +++ b/tests/e2e/keybind-functionality.spec.js @@ -0,0 +1,711 @@ +// 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, 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"; + +/** + * 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); + + // 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(() => { + 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); + }); + + 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 cancel in-progress annotation, exit brush mode, and exit erase mode", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Switch to polygon mode + await page.click("a#md-btn--polygon"); + await page.waitForTimeout(200); + + // 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); + 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 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); + 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); + }); + + 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/e2e/keybinds.spec.js b/tests/e2e/keybinds.spec.js new file mode 100644 index 00000000..49435e6c --- /dev/null +++ b/tests/e2e/keybinds.spec.js @@ -0,0 +1,233 @@ +// 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(() => { + return localStorage.getItem("ulabel_custom_keybinds"); + }); + // 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 --- + + // 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(() => { + return localStorage.getItem("ulabel_custom_keybinds"); + }); + // 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 --- + + // 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(() => { + return localStorage.getItem("ulabel_custom_class_keybinds"); + }); + // 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"); + 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("Control+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 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 (stored as double-stringified JSON) + const collapsed = await page.evaluate(() => { + return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); + }); + expect(collapsed).toBe(JSON.stringify("true")); + + // Click to expand again + await configurableSection.click(); + await expect(configurableItems).not.toHaveClass(/collapsed/); + + // Verify localStorage (stored as double-stringified JSON) + const expanded = await page.evaluate(() => { + return localStorage.getItem("ulabel_keybind_section_configurable_collapsed"); + }); + expect(expanded).toBe(JSON.stringify("false")); + }); +}); 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; +}