diff --git a/eslint.config.mjs b/eslint.config.mjs index c3ab7f5b..c7566b08 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,7 @@ export default [ }, stylistic.configs.customize( { + arrowParens: true, braceStyle: "1tbs", commaDangle: "always-multiline", indent: 4, diff --git a/index.d.ts b/index.d.ts index 6e6c6019..43d73f0e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -63,6 +63,7 @@ export type ClassDefinition = { name: string; id: number; color: string; + keybind?: string; }; export type SliderInfo = { @@ -194,6 +195,7 @@ export class ULabel { valid_class_ids: number[]; toolbox_order?: number[]; filter_distance_overlay?: FilterDistanceOverlay; + resize_observers: ResizeObserver[]; /** * @link https://github.com/SenteraLLC/ulabel/blob/main/api_spec.md#ulabel-constructor */ @@ -221,8 +223,15 @@ export class ULabel { public show_whole_image(): void; public swap_frame_image(new_src: string, frame?: number): string; public swap_anno_bg_color(new_bg_color: string): string; + + // Subtasks public get_current_subtask_key(): string; public get_current_subtask(): ULabelSubtask; + public readjust_subtask_opacities(): void; + public set_subtask(st_key: string): void; + public switch_to_next_subtask(): void; + + // Annotations public get_annotations(subtask: ULabelSubtask): ULabelAnnotation[]; public set_annotations(annotations: ULabelAnnotation[], subtask: ULabelSubtask); public set_saved(saved: boolean); @@ -245,14 +254,108 @@ export class ULabel { dist_prop: number; }, ): void; - public toggle_erase_mode(mouse_event: JQuery.TriggeredEvent): void; - public toggle_brush_mode(mouse_event: JQuery.TriggeredEvent): void; + + // Brush + // TODO (joshua-dean): should these actually be optional? + public toggle_erase_mode(mouse_event?: JQuery.TriggeredEvent): void; + public toggle_brush_mode(mouse_event?: JQuery.TriggeredEvent): void; public toggle_delete_class_id_in_toolbox(): void; public change_brush_size(scale_factor: number): void; + public recolor_brush_circle(): void; + public destroy_brush_circle(): void; + + // Listeners public remove_listeners(): void; static get_allowed_toolbox_item_enum(): AllowedToolboxItem; static process_classes(ulabel_obj: ULabel, arg1: string, subtask_obj: ULabelSubtask); static build_id_dialogs(ulabel_obj: ULabel); + + // Annotation lifecycle + // TODO (joshua-dean): type for redo_payload + public begin_annotation(mouse_event: JQuery.TriggeredEvent, redo_payload?: object): void; + public create_annotation( + spatial_type: ULabelSpatialType, + spatial_payload: ULabelSpatialPayload, + unique_id?: string, + ): void; + public create_nonspatial_annotation( + redo_payload?: object, + ): void; + public delete_annotation( + annotation_id: string, + redo_payload?: object, + record_action?: boolean, + ): void; + public cancel_annotation(redo_payload?: object): void; + public get_active_class_id(): number; + public get_active_class_id_idx(): number; + public undo(is_internal_undo?: boolean): void; + public redo(): void; + + // Mouse event handlers + public handle_mouse_down(mouse_event: JQuery.TriggeredEvent): void; + public handle_mouse_move(mouse_event: JQuery.TriggeredEvent): void; + public handle_mouse_up(mouse_event: JQuery.TriggeredEvent): void; + public handle_aux_click(mouse_event: JQuery.TriggeredEvent): void; + public handle_wheel(wheel_event: WheelEvent): void; + public start_drag( + drag_key: string, + release_button: string, + mouse_event: JQuery.TriggeredEvent, + ): void; + public end_drag(mouse_event: JQuery.TriggeredEvent): void; + public drag_repan(mouse_event: JQuery.TriggeredEvent): void; + public drag_rezoom(mouse_event: JQuery.TriggeredEvent): void; + + // "Mouse event interpreters" + public get_global_mouse_x(mouse_event: JQuery.TriggeredEvent): number; + public get_global_mouse_y(mouse_event: JQuery.TriggeredEvent): number; + + // Edit suggestions + public suggest_edits( + mouse_event?: JQuery.TriggeredEvent, + nonspatial_id?: string, + ): void; + public show_global_edit_suggestion( + annid: string, + offset?: { + diffX: number; + diffY: number; + diffZ?: number; + }, + nonspatial_id?: string, + ): void; + public hide_global_edit_suggestion(): void; + + // Drawing + public rezoom( + foc_x?: number, + foc_y?: number, + abs?: boolean, + ): void; + public reposition_dialogs(): void; + public handle_toolbox_overflow(): void; + + // ID Dialog + public set_id_dialog_payload_nopin( + class_ind: number, + dist_prop: number + ): void; + public update_id_dialog_display( + front?: boolean, + ): void; + public handle_id_dialog_click( + mouse_event: JQuery.TriggeredEvent, + annotation_id?: string, + new_class_idx?: number, + ): void; + public show_id_dialog( + gbx: number, + gby: number, + active_ann: string, // annotation id + thumbnail?: boolean, + nonspatial?: boolean, + ): void; } declare global { diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 00000000..d1fc2dcf --- /dev/null +++ b/src/cookies.ts @@ -0,0 +1,45 @@ +/** + * ULabel cookie utilities. + */ + +export abstract class NightModeCookie { + /** + * The name of the cookie that stores the night mode preference. + */ + public static readonly COOKIE_NAME: string = "nightmode"; + + /** + * Return whether the document has a night mode cookie. + */ + public static exists_in_document(): boolean { + const cookie_components = document.cookie.split(";"); + const night_mode_comp = cookie_components.find( + row => row.trim().startsWith(`${NightModeCookie.COOKIE_NAME}=true`), + ); + return night_mode_comp !== undefined; + } + + /** + * Set the night mode cookie. + */ + public static set_cookie(): void { + const d = new Date(); + d.setTime(d.getTime() + (10000 * 24 * 60 * 60 * 1000)); + document.cookie = [ + NightModeCookie.COOKIE_NAME + "=true", + "expires=" + d.toUTCString(), + "path=/", + ].join(";"); + } + + /** + * Destroy the night mode cookie. + */ + public static destroy_cookie() { + document.cookie = [ + NightModeCookie.COOKIE_NAME + "=true", + "expires=Thu, 01 Jan 1970 00:00:00 UTC", + "path=/", + ].join(";"); + } +} diff --git a/src/index.js b/src/index.js index ac51857e..4ce2c7e5 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,9 @@ import { build_confidence_dialog, } from "../build/html_builder"; +import { create_ulabel_listeners, remove_ulabel_listeners } from "../build/listeners"; +import { NightModeCookie } from "../build/cookies"; + import $ from "jquery"; const jQuery = $; @@ -84,25 +87,6 @@ export class ULabel { return (new Date()).toISOString(); } - // =========================== NIGHT MODE COOKIES ======================================= - - static has_night_mode_cookie() { - if (document.cookie.split(";").find(row => row.trim().startsWith("nightmode=true"))) { - return true; - } - return false; - } - - static set_night_mode_cookie() { - let d = new Date(); - d.setTime(d.getTime() + (10000 * 24 * 60 * 60 * 1000)); - document.cookie = "nightmode=true;expires=" + d.toUTCString() + ";path=/"; - } - - static destroy_night_mode_cookie() { - document.cookie = "nightmode=true;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/"; - } - static get_allowed_toolbox_item_enum() { return AllowedToolboxItem; } @@ -162,387 +146,7 @@ export class ULabel { * Note that ULabel will not function properly after this method is called. */ remove_listeners() { - // Remove jquery event listeners - $(document).off(".ulabel"); // Unbind all events in the ulabel namespace from document - $(window).off(".ulabel"); // Unbind all events in the ulabel namespace from window - $(".id_dialog").off(".ulabel"); // Unbind all events in the ulabel namespace from .id_dialog - - // Go through each resize observer and disconnect them - if (this.resize_observers != null) { - this.resize_observers.forEach((observer) => { - observer.disconnect(); - }); - } - } - - static create_listeners(ul) { - // ================= Mouse Events in the ID Dialog ================= - - var iddg = $(".id_dialog"); - - // Hover interactions - - iddg.on("mousemove.ulabel", function (mouse_event) { - if (!ul.get_current_subtask()["state"]["idd_thumbnail"]) { - ul.handle_id_dialog_hover(mouse_event); - } - }); - - // ================= Mouse Events in the Annotation Container ================= - - var annbox = $("#" + ul.config["annbox_id"]); - - // Detect and record mousedown - annbox.on("mousedown.ulabel", (e) => { - ul.handle_mouse_down(e); - }); - - // Prevent default for auxclick - $(document).on("auxclick.ulabel", ul.handle_aux_click); - - // Detect and record mouseup - $(document).on("mouseup.ulabel", ul.handle_mouse_up.bind(ul)); - - $(window).on("click.ulabel", (e) => { - if (e.shiftKey) { - e.preventDefault(); - } - }); - - // Mouse movement has meaning in certain cases - annbox.on("mousemove.ulabel", (e) => { - ul.handle_mouse_move(e); - }); - - $(document).on("keypress.ulabel", (e) => { - // Check for the correct keypress - // Grab current subtask - const current_subtask = ul.get_current_subtask(); - switch (e.key) { - // Create a point annotation at the mouse's current location - case ul.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 - ul.begin_annotation(ul.state["last_move"]); - } - break; - // Create a bbox annotation around the initial_crop. Or the whole image if inital_crop does not exist - case ul.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 = [0, 0]; - let bbox_bottom_right = [ul.config.image_width, ul.config.image_height]; - - // If an initial crop exists, use that instead - if (ul.config.initial_crop !== null && ul.config.initial_crop !== undefined) { - // Convenience - const initial_crop = ul.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 - ul.create_annotation(current_subtask.state.annotation_mode, [bbox_top_left, bbox_bottom_right]); - } - break; - // Change to brush mode (for now, polygon only) - case ul.config.toggle_brush_mode_keybind: - ul.toggle_brush_mode(ul.state["last_move"]); - break; - // Change to erase mode (will also set the is_in_brush_mode state) - case ul.config.toggle_erase_mode_keybind: - ul.toggle_erase_mode(ul.state["last_move"]); - break; - // Increase brush size by 10% - case ul.config.increase_brush_size_keybind: - ul.change_brush_size(1.1); - break; - // Decrease brush size by 10% - case ul.config.decrease_brush_size_keybind: - ul.change_brush_size(1 / 1.1); - break; - case ul.config.change_zoom_keybind.toLowerCase(): - ul.show_initial_crop(); - break; - case ul.config.change_zoom_keybind.toUpperCase(): - ul.show_whole_image(); - break; - default: - 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 && e.key === class_def.keybind) { - let class_button = $(`#tb-id-app--${ul.get_current_subtask_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 - // Get the active annotation, if any - 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 - ul.handle_id_dialog_click(ul.state["last_move"], target_id, ul.get_active_class_id_idx()); - } - } else { - // Click the class button if not already selected - class_button.trigger("click"); - } - return; - } - } - } - - break; - } - }); - - // This listener does not use jquery because it requires being able to prevent default - // There are maybe some hacky ways to do this with jquery - // https://stackoverflow.com/questions/60357083/does-not-use-passive-listeners-to-improve-scrolling-performance-lighthouse-repo - // Detection ctrl+scroll - document.getElementById(ul.config["annbox_id"]).addEventListener("wheel", ul.handle_wheel.bind(ul)); - - // Create a resize observer to reposition dialogs - let dialog_resize_observer = new ResizeObserver(function () { - ul.reposition_dialogs(); - }); - - // Observe the changes on the imwrap_id element - dialog_resize_observer.observe(document.getElementById(ul.config["imwrap_id"])); - - // Store a reference - ul.resize_observers.push(dialog_resize_observer); - - // Create a resize observer to handle toolbox overflow - let tb_overflow_resize_observer = new ResizeObserver(function () { - ul.handle_toolbox_overflow(); - }); - - // Observe the changes on the ulabel container - tb_overflow_resize_observer.observe(document.getElementById(ul.config["container_id"])); - - // Store a reference - ul.resize_observers.push(tb_overflow_resize_observer); - - // Listener for soft id toolbox buttons - $(document).on("click.ulabel", "#" + ul.config["toolbox_id"] + " a.tbid-opt", (e) => { - let tgt_jq = $(e.currentTarget); - let pfx = "div#tb-id-app--" + ul.get_current_subtask_key(); - const current_subtask = ul.get_current_subtask(); - if (tgt_jq.attr("href") === "#") { - const current_id_button = $(pfx + " a.tbid-opt.sel"); - current_id_button.attr("href", "#"); - current_id_button.removeClass("sel"); - const old_id = parseInt(current_id_button.attr("id").split("_").at(-1)); - tgt_jq.addClass("sel"); - tgt_jq.removeAttr("href"); - let idarr = tgt_jq.attr("id").split("_"); - let rawid = parseInt(idarr[idarr.length - 1]); - ul.set_id_dialog_payload_nopin(current_subtask["class_ids"].indexOf(rawid), 1.0); - ul.update_id_dialog_display(); - - // Update the class of the active annotation, except when toggling on the delete class - if (rawid !== DELETE_CLASS_ID) { - // Get the active annotation, if any - 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 - ul.handle_id_dialog_click(ul.state["last_move"], target_id, ul.get_active_class_id_idx()); - } else { - // If there is not active annotation, still update the brush circle if in brush mode - ul.recolor_brush_circle(); - } - } - - // If toggling off a delete class while still in delete mode, re-toggle the delete class - // This occurs when using a keybind to change a hovered annotation's class while in delete mode - if (old_id === DELETE_CLASS_ID && DELETE_MODES.includes(current_subtask.state.annotation_mode)) { - $("#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); - } - } - }); - - $(document).on("click.ulabel", "a.tb-st-switch[href]", (e) => { - let switch_to = $(e.target).attr("id").split("--")[1]; - - // Ignore if in the middle of annotation - if (ul.get_current_subtask()["state"]["is_in_progress"]) { - return; - } - - ul.set_subtask(switch_to); - }); - - // Keybind to switch active subtask - $(document).on("keypress.ulabel", (e) => { - // Ignore if in the middle of annotation - if (ul.get_current_subtask()["state"]["is_in_progress"]) { - return; - } - - // Check for the right keypress - if (e.key === ul.config.switch_subtask_keybind) { - ul.switch_to_next_subtask(); - } - }); - - $(document).on("input.ulabel", "input.frame_input", () => { - ul.update_frame(); - }); - - $(document).on("input.ulabel", "span.tb-st-range input", () => { - ul.readjust_subtask_opacities(); - }); - - $(document).on("click.ulabel", "div.fad_row.add a.add-glob-button", () => { - ul.create_nonspatial_annotation(); - }); - $(document).on("focus.ulabel", "textarea.nonspatial_note", () => { - $("div.frame_annotation_dialog.active").addClass("permopen"); - }); - $(document).on("focusout.ulabel", "textarea.nonspatial_note", () => { - $("div.frame_annotation_dialog.permopen").removeClass("permopen"); - }); - $(document).on("input.ulabel", "textarea.nonspatial_note", (e) => { - // Update annotation's text field - ul.get_current_subtask()["annotations"]["access"][e.target.id.substring("note__".length)]["text_payload"] = e.target.value; - }); - $(document).on("click.ulabel", "a.fad_button.delete", (e) => { - ul.delete_annotation(e.target.id.substring("delete__".length)); - }); - $(document).on("click.ulabel", "a.fad_button.reclf", (e) => { - // Show idd - ul.show_id_dialog(e.pageX, e.pageY, e.target.id.substring("reclf__".length), false, true); - }); - - $(document).on("mouseenter.ulabel", "div.fad_annotation_rows div.fad_row", (e) => { - // Show thumbnail for idd - ul.suggest_edits(null, $(e.currentTarget).attr("id").substring("row__".length)); - }); - $(document).on("mouseleave.ulabel", "div.fad_annotation_rows div.fad_row", () => { - // Show thumbnail for idd - if ( - ul.get_current_subtask()["state"]["idd_visible"] && - !ul.get_current_subtask()["state"]["idd_thumbnail"] - ) { - return; - } - ul.suggest_edits(null); - }); - $(document).on("keypress.ulabel", (e) => { - // Check the key pressed against the delete annotation keybind in the config - if (e.key === ul.config.delete_annotation_keybind) { - // Check the edit_candidate to make sure its not null and isn't nonspatial - if ( - ul.get_current_subtask().state.edit_candidate != null && - !NONSPATIAL_MODES.includes(ul.get_current_subtask().state.edit_candidate.spatial_type) - ) { - // Delete the active annotation - ul.delete_annotation(ul.get_current_subtask().state.edit_candidate.annid); - } - } - }); - - // Listener for id_dialog click interactions - $(document).on("click.ulabel", "#" + ul.config["container_id"] + " a.id-dialog-clickable-indicator", (e) => { - if (!ul.get_current_subtask()["state"]["idd_thumbnail"]) { - ul.handle_id_dialog_click(e); - } else { - // It's always covered up as a thumbnail. See below - } - }); - $(document).on("click.ulabel", ".global_edit_suggestion a.reid_suggestion", (e) => { - let crst = ul.get_current_subtask(); - let annid = crst["state"]["idd_associated_annotation"]; - ul.hide_global_edit_suggestion(); - ul.show_id_dialog( - ul.get_global_mouse_x(e), - ul.get_global_mouse_y(e), - annid, - false, - ); - }); - - $(document).on("click.ulabel", "#" + ul.config["annbox_id"] + " .delete_suggestion", () => { - let crst = ul.get_current_subtask(); - ul.delete_annotation(crst["state"]["move_candidate"]["annid"]); - }); - - // Button to save annotations - $(document).on("click.ulabel", "#" + ul.config["toolbox_id"] + " a.night-button", function () { - if ($("#" + ul.config["container_id"]).hasClass("ulabel-night")) { - $("#" + ul.config["container_id"]).removeClass("ulabel-night"); - // Destroy any night cookie - ULabel.destroy_night_mode_cookie(); - } else { - $("#" + ul.config["container_id"]).addClass("ulabel-night"); - // Drop a night cookie - ULabel.set_night_mode_cookie(); - } - }); - - // Keyboard only events - $(document).on("keydown.ulabel", (keypress_event) => { - const shift = keypress_event.shiftKey; - const ctrl = keypress_event.ctrlKey || keypress_event.metaKey; - if (ctrl && - ( - keypress_event.key === "z" || - keypress_event.key === "Z" || - keypress_event.code === "KeyZ" - ) - ) { - keypress_event.preventDefault(); - if (shift) { - ul.redo(); - } else { - ul.undo(); - } - return false; - } else { - const current_subtask = ul.get_current_subtask(); - switch (keypress_event.key) { - case "Escape": - // If in erase or brush mode, cancel the brush - if (current_subtask.state.is_in_erase_mode) { - ul.toggle_erase_mode(); - } else if (current_subtask.state.is_in_brush_mode) { - ul.toggle_brush_mode(); - } else if (current_subtask.state.starting_complex_polygon) { - // If starting a complex polygon, undo - ul.undo(); - } else if (current_subtask.state.is_in_progress) { - // If in the middle of drawing an annotation, cancel the annotation - ul.cancel_annotation(); - } - break; - } - } - }); - - $(window).on("beforeunload.ulabel", () => { - if (ul.state["edited"]) { - // Return of anything other than `undefined` will trigger the browser's confirmation dialog - // Custom messages are not supported - return 1; - } - }); + remove_ulabel_listeners(this); } static process_allowed_modes(ul, subtask_key, subtask) { @@ -1094,7 +698,7 @@ export class ULabel { prep_window_html(this, this.config.toolbox_order); // Detect night cookie - if (ULabel.has_night_mode_cookie()) { + if (NightModeCookie.exists_in_document()) { $("#" + this.config["container_id"]).addClass("ulabel-night"); } @@ -1154,7 +758,7 @@ export class ULabel { build_confidence_dialog(that); // Create listers to manipulate and export this object - ULabel.create_listeners(that); + create_ulabel_listeners(that); that.handle_toolbox_overflow(); diff --git a/src/listeners.ts b/src/listeners.ts new file mode 100644 index 00000000..49ba6630 --- /dev/null +++ b/src/listeners.ts @@ -0,0 +1,579 @@ +/** + * ULabel listener utilities. + * + * These primarily use JQuery, within the namespace "ulabel". + * Selectors and `.on` calls are intentionally kept in `create_ulabel_listeners`. + * Long handlers are broken out into separate functions. + */ + +import type { ULabel } from ".."; +import { NightModeCookie } from "./cookies"; +import { DELETE_CLASS_ID, DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; + +const ULABEL_NAMESPACE = ".ulabel"; + +/** + * Handle keypress events. + * + * @param keypress_event Key event to handle + * @param ulabel ULabel instance + */ +function handle_keypress_event( + keypress_event: JQuery.KeyPressEvent, + ulabel: ULabel, +) { + 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.begin_annotation(ulabel.state["last_move"]); + } + 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], + ); + } + 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; + } + } + } + break; + } +} + +/** + * Handle a click on a soft ID toolbox button. + * + * @param click_event Click event + * @param ulabel ULabel instance + */ +function handle_soft_id_toolbox_button_click( + click_event: JQuery.ClickEvent, + ulabel: ULabel, +) { + const tgt_jq = $(click_event.currentTarget); + const pfx = "div#tb-id-app--" + ulabel.get_current_subtask_key(); + const current_subtask = ulabel.get_current_subtask(); + if (tgt_jq.attr("href") === "#") { + const current_id_button = $(pfx + " a.tbid-opt.sel"); + current_id_button.attr("href", "#"); + current_id_button.removeClass("sel"); + const old_id = parseInt(current_id_button.attr("id").split("_").at(-1)); + tgt_jq.addClass("sel"); + tgt_jq.removeAttr("href"); + const idarr = tgt_jq.attr("id").split("_"); + const rawid = parseInt(idarr[idarr.length - 1]); + ulabel.set_id_dialog_payload_nopin( + current_subtask["class_ids"].indexOf(rawid), + 1.0, + ); + ulabel.update_id_dialog_display(); + + // Update the class of the active annotation, + // except when toggling on the delete class + if (rawid !== DELETE_CLASS_ID) { + // Get the active annotation, if any + 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 { + // If there is not active annotation, + // still update the brush circle if in brush mode + ulabel.recolor_brush_circle(); + } + } + + /* + If toggling off a delete class while still in delete mode, + re-toggle the delete class. + This occurs when using a keybind to change a hovered annotation's + class while in delete mode. + */ + if ( + old_id === DELETE_CLASS_ID && + DELETE_MODES.includes(current_subtask.state.annotation_mode) + ) { + $("#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); + } + } +} + +/** + * Handler for ULabel keydown events. + * + * @param keydown_event Event to handle + * @param ulabel ULabel instance + * @returns Whether the event was handled + */ +function handle_keydown_event( + keydown_event: JQuery.KeyDownEvent, + ulabel: ULabel, +): boolean { + const shift = keydown_event.shiftKey; + const ctrl = keydown_event.ctrlKey || keydown_event.metaKey; + const key_is_z = ( + keydown_event.key === "z" || + keydown_event.key === "Z" || + keydown_event.code === "KeyZ" + ); + + if (ctrl && key_is_z) { + keydown_event.preventDefault(); + if (shift) { + ulabel.redo(); + } else { + ulabel.undo(); + } + return false; + } else { + const current_subtask = ulabel.get_current_subtask(); + switch (keydown_event.key) { + 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; + } + } +} + +/** + * Create listeners for a ULabel instance. + * Inline handlers must be arrow functions. + * Consider breaking out long handlers. + * + * @param ulabel ULabel instance + */ +export function create_ulabel_listeners( + ulabel: ULabel, +) { + // ================= Mouse Events in the ID Dialog ================= + const id_dialog = $(".id_dialog"); + id_dialog.on( + "mousemove" + ULABEL_NAMESPACE, + (mouse_event) => { + if (!ulabel.get_current_subtask()["state"]["idd_thumbnail"]) { + ulabel.handle_id_dialog_hover(mouse_event); + } + }, + ); + + // ================= Mouse Events in the Annotation Container ================= + const annbox = $("#" + ulabel.config["annbox_id"]); + + // Detect and record mousedown + annbox.on( + "mousedown" + ULABEL_NAMESPACE, + (click_event) => ulabel.handle_mouse_down(click_event), + ); + + // Prevent default for auxclick + $(document).on( + "auxclick" + ULABEL_NAMESPACE, + (mouse_event) => ulabel.handle_aux_click(mouse_event), + ); + + // Detect and record mouseup + $(document).on( + "mouseup" + ULABEL_NAMESPACE, + (mouseup_event) => ulabel.handle_mouse_up(mouseup_event), + ); + + $(window).on( + "click" + ULABEL_NAMESPACE, + (click_event) => { + if (click_event.shiftKey) { + click_event.preventDefault(); + } + }, + ); + + // Mouse movement has meaning in certain cases + annbox.on( + "mousemove" + ULABEL_NAMESPACE, + (move_event) => ulabel.handle_mouse_move(move_event), + ); + + // ================= Uncategorized ================= + + $(document).on( + "keypress" + ULABEL_NAMESPACE, + (keypress_event: JQuery.KeyPressEvent) => { + handle_keypress_event(keypress_event, ulabel); + }, + ); + + // This listener does not use jquery because it requires being able to prevent default + // There are maybe some hacky ways to do this with jquery + // https://stackoverflow.com/questions/60357083/does-not-use-passive-listeners-to-improve-scrolling-performance-lighthouse-repo + // Detection ctrl+scroll + document.getElementById( + ulabel.config["annbox_id"], + ).addEventListener( + "wheel", + (wheel_event) => ulabel.handle_wheel(wheel_event), + ); + + // Create a resize observer to reposition dialogs + const dialog_resize_observer = new ResizeObserver( + () => ulabel.reposition_dialogs(), + ); + + // Observe the changes on the imwrap_id element + dialog_resize_observer.observe( + document.getElementById(ulabel.config["imwrap_id"]), + ); + + // Store a reference + ulabel.resize_observers.push(dialog_resize_observer); + + // Create a resize observer to handle toolbox overflow + const tb_overflow_resize_observer = new ResizeObserver( + () => ulabel.handle_toolbox_overflow(), + ); + + // Observe the changes on the ulabel container + tb_overflow_resize_observer.observe( + document.getElementById(ulabel.config["container_id"]), + ); + + // Store a reference + ulabel.resize_observers.push(tb_overflow_resize_observer); + + // create_soft_id_toolbox_button_listener(ulabel); + $(document).on( + "click" + ULABEL_NAMESPACE, + `#${ulabel.config["toolbox_id"]} a.tbid-opt`, + (click_event: JQuery.ClickEvent) => { + handle_soft_id_toolbox_button_click(click_event, ulabel); + }, + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + "a.tb-st-switch[href]", + (click_event) => { + const switch_to = $(click_event.target).attr("id").split("--")[1]; + + // Ignore if in the middle of annotation + if (ulabel.get_current_subtask()["state"]["is_in_progress"]) return; + + ulabel.set_subtask(switch_to); + }, + ); + + // Keybind to switch active subtask + $(document).on( + "keypress" + ULABEL_NAMESPACE, + (keypress_event) => { + // 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) { + ulabel.switch_to_next_subtask(); + } + }, + ); + + $(document).on( + "input" + ULABEL_NAMESPACE, + "input.frame_input", + () => ulabel.update_frame(), + ); + + $(document).on( + "input" + ULABEL_NAMESPACE, + "span.tb-st-range input", + () => ulabel.readjust_subtask_opacities(), + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + "div.fad_row.add a.add-glob-button", + () => ulabel.create_nonspatial_annotation(), + ); + + $(document).on( + "focus" + ULABEL_NAMESPACE, + "textarea.nonspatial_note", + () => $("div.frame_annotation_dialog.active").addClass("permopen"), + ); + + $(document).on( + "focusout" + ULABEL_NAMESPACE, + "textarea.nonspatial_note", + () => $("div.frame_annotation_dialog.permopen").removeClass("permopen"), + ); + + $(document).on( + "input" + ULABEL_NAMESPACE, + "textarea.nonspatial_note", + (input_event) => { + // Update annotation's text field + const annos = ulabel.get_current_subtask()["annotations"]["access"]; + const text_payload_anno_id = input_event.target.id.substring("note__".length); + annos[text_payload_anno_id]["text_payload"] = input_event.target.value; + }, + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + "a.fad_button.delete", + (click_event) => { + ulabel.delete_annotation(click_event.target.id.substring("delete__".length)); + }, + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + "a.fad_button.reclf", + (click_event) => { + // Show idd + ulabel.show_id_dialog( + click_event.pageX, + click_event.pageY, + click_event.target.id.substring("reclf__".length), + false, + true, + ); + }, + ); + + $(document).on( + "mouseenter" + ULABEL_NAMESPACE, + "div.fad_annotation_rows div.fad_row", + (mouse_event) => { + // Show thumbnail for idd + ulabel.suggest_edits( + null, + $(mouse_event.currentTarget).attr("id").substring("row__".length), + ); + }, + ); + + $(document).on( + "mouseleave" + ULABEL_NAMESPACE, + "div.fad_annotation_rows div.fad_row", + () => { + // Show thumbnail for idd + if ( + ulabel.get_current_subtask()["state"]["idd_visible"] && + !ulabel.get_current_subtask()["state"]["idd_thumbnail"] + ) { + return; + } + ulabel.suggest_edits(null); + }, + ); + + $(document).on( + "keypress" + ULABEL_NAMESPACE, + (keypress_event) => { + // Check the key pressed against the delete annotation keybind in the config + if (keypress_event.key === 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)) { + ulabel.delete_annotation(edit_cand.annid); + } + } + }, + ); + + // Listener for id_dialog click interactions + $(document).on( + "click" + ULABEL_NAMESPACE, + "#" + ulabel.config["container_id"] + " a.id-dialog-clickable-indicator", + (click_event) => { + if (!ulabel.get_current_subtask()["state"]["idd_thumbnail"]) { + ulabel.handle_id_dialog_click(click_event); + } + }, + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + ".global_edit_suggestion a.reid_suggestion", + (click_event) => { + const crst = ulabel.get_current_subtask(); + const annid = crst["state"]["idd_associated_annotation"]; + ulabel.hide_global_edit_suggestion(); + ulabel.show_id_dialog( + ulabel.get_global_mouse_x(click_event), + ulabel.get_global_mouse_y(click_event), + annid, + false, + ); + }, + ); + + $(document).on( + "click" + ULABEL_NAMESPACE, + "#" + ulabel.config["annbox_id"] + " .delete_suggestion", + () => { + const crst = ulabel.get_current_subtask(); + ulabel.delete_annotation(crst["state"]["move_candidate"]["annid"]); + }, + ); + + // Button to save annotations + $(document).on( + "click" + ULABEL_NAMESPACE, + "#" + ulabel.config["toolbox_id"] + " a.night-button", + () => { + const root_container = $("#" + ulabel.config["container_id"]); + if (root_container.hasClass("ulabel-night")) { + root_container.removeClass("ulabel-night"); + NightModeCookie.destroy_cookie(); + } else { + root_container.addClass("ulabel-night"); + NightModeCookie.set_cookie(); + } + }, + ); + + // Keyboard only events + $(document).on( + "keydown" + ULABEL_NAMESPACE, + (keydown_event: JQuery.KeyDownEvent) => { + handle_keydown_event(keydown_event, ulabel); + }, + ); + + $(window).on( + "beforeunload" + ULABEL_NAMESPACE, + () => { + if (ulabel.state["edited"]) { + // Return of anything other than `undefined` + // will trigger the browser's confirmation dialog + // Custom messages are not supported + return 1; + } + }, + ); +} + +/** + * Remove listeners from a ULabel instance. + * + * @param ulabel ULabel instance. + */ +export function remove_ulabel_listeners( + ulabel: ULabel, +) { + // Remove jquery event listeners with the ulabel namespace + $(document).off(ULABEL_NAMESPACE); + $(window).off(ULABEL_NAMESPACE); + $(".id_dialog").off(ULABEL_NAMESPACE); + + // Go through each resize observer and disconnect them + if (ulabel.resize_observers != null) { + ulabel.resize_observers.forEach((observer) => { + observer.disconnect(); + }); + } +} diff --git a/src/subtask.ts b/src/subtask.ts index cf3b44e1..a271a430 100644 --- a/src/subtask.ts +++ b/src/subtask.ts @@ -47,6 +47,7 @@ export class ULabelSubtask { access: string | number | [number, number]; distance: number; point: [number, number]; // Mouse location + spatial_type: ULabelSpatialType; }; first_explicit_assignment: boolean; front_context: CanvasRenderingContext2D; @@ -54,7 +55,7 @@ export class ULabelSubtask { class_id: number; confidence: number; }[]; - idd_associated_annotation: unknown; // TODO: figure out what type this is + idd_associated_annotation: string; idd_id: string; idd_id_front: string; idd_thumbnail: boolean; @@ -69,6 +70,7 @@ export class ULabelSubtask { visible_dialogs: { [key: string]: ULabelDialogPosition; }; + spatial_type: ULabelSpatialType; }; constructor(