diff --git a/app/packages/commands/src/context/CommandContextManager.ts b/app/packages/commands/src/context/CommandContextManager.ts index d4928e9de0b..30af57f2b7e 100644 --- a/app/packages/commands/src/context/CommandContextManager.ts +++ b/app/packages/commands/src/context/CommandContextManager.ts @@ -25,6 +25,7 @@ export enum KnownCommands { ModalNextSample = "fo.modal.next.sample", ModalPreviousSample = "fo.modal.previous.sample", ModalDeleteAnnotation = "fo.modal.delete.annotation", + ModalAnnotateDeselect = "fo.modal.annotate.deselect", } //callback for context changes export type CommandContextListener = (newId: string) => void; diff --git a/app/packages/commands/src/context/context.manager.test.ts b/app/packages/commands/src/context/context.manager.test.ts index 57a92eb8def..d19b07b0b7b 100644 --- a/app/packages/commands/src/context/context.manager.test.ts +++ b/app/packages/commands/src/context/context.manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, vi, expect, beforeEach } from "vitest"; -import { CommandContextManager, KnownContexts } from "./CommandContextManager"; +import { CommandContextManager, KnownCommands, KnownContexts } from "./CommandContextManager"; import { DelegatingUndoable } from "../actions"; describe("CommandContextManager", () => { @@ -135,6 +135,141 @@ describe("CommandContextManager", () => { expect(execFn).toBeCalledTimes(0); }); + describe("Escape key priority: ModalAnnotate overrides Modal", () => { + // Simulates the annotate-tab Escape behavior: + // When a label is selected (Header mounted), ModalAnnotate's Escape binding + // should fire instead of Modal's close handler. + // When the label is deselected (Header unmounted / binding unregistered), + // Modal's Escape should fire again. + + const escapeEvent = () => + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); + + it("ModalAnnotate Escape fires instead of Modal Escape when both are bound", async () => { + const modalClose = vi.fn(); + const annotateDeselect = vi.fn(); + + const manager = CommandContextManager.instance(); + + const modalCtx = manager.getCommandContext(KnownContexts.Modal)!; + const annotateCtx = manager.getCommandContext(KnownContexts.ModalAnnotate)!; + + const modalCmd = modalCtx.registerCommand("fo.modal.close.test", modalClose, () => true); + modalCtx.bindKey("Escape", modalCmd.id); + + const annotateCmd = annotateCtx.registerCommand( + KnownCommands.ModalAnnotateDeselect, + annotateDeselect, + () => true + ); + annotateCtx.bindKey("Escape", annotateCmd.id); + + await manager.handleKeyDown(escapeEvent()); + + expect(annotateDeselect).toHaveBeenCalledOnce(); + expect(modalClose).not.toHaveBeenCalled(); + }); + + it("Modal Escape fires when ModalAnnotate has no Escape binding", async () => { + const modalClose = vi.fn(); + + const manager = CommandContextManager.instance(); + const modalCtx = manager.getCommandContext(KnownContexts.Modal)!; + + const modalCmd = modalCtx.registerCommand("fo.modal.close.test", modalClose, () => true); + modalCtx.bindKey("Escape", modalCmd.id); + + await manager.handleKeyDown(escapeEvent()); + + expect(modalClose).toHaveBeenCalledOnce(); + }); + + it("Modal Escape fires again after ModalAnnotate Escape binding is unregistered", async () => { + const modalClose = vi.fn(); + const annotateDeselect = vi.fn(); + + const manager = CommandContextManager.instance(); + const modalCtx = manager.getCommandContext(KnownContexts.Modal)!; + const annotateCtx = manager.getCommandContext(KnownContexts.ModalAnnotate)!; + + const modalCmd = modalCtx.registerCommand("fo.modal.close.test", modalClose, () => true); + modalCtx.bindKey("Escape", modalCmd.id); + + const annotateCmd = annotateCtx.registerCommand( + KnownCommands.ModalAnnotateDeselect, + annotateDeselect, + () => true + ); + annotateCtx.bindKey("Escape", annotateCmd.id); + + // Simulate Header mounting: annotate Escape fires + await manager.handleKeyDown(escapeEvent()); + expect(annotateDeselect).toHaveBeenCalledOnce(); + expect(modalClose).not.toHaveBeenCalled(); + + // Simulate Header unmounting: unregister the ModalAnnotate binding + annotateCtx.unbindKey("Escape"); + annotateCtx.unregisterCommand(annotateCmd.id); + + vi.clearAllMocks(); + + // Now Modal Escape takes over + await manager.handleKeyDown(escapeEvent()); + expect(modalClose).toHaveBeenCalledOnce(); + expect(annotateDeselect).not.toHaveBeenCalled(); + }); + + it("Escape is blocked when an input element is focused", async () => { + const annotateDeselect = vi.fn(); + + const manager = CommandContextManager.instance(); + const annotateCtx = manager.getCommandContext(KnownContexts.ModalAnnotate)!; + + const annotateCmd = annotateCtx.registerCommand( + KnownCommands.ModalAnnotateDeselect, + annotateDeselect, + () => true + ); + annotateCtx.bindKey("Escape", annotateCmd.id); + + // Simulate focus on an input field + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + await manager.handleKeyDown(escapeEvent()); + + expect(annotateDeselect).not.toHaveBeenCalled(); + + input.remove(); + }); + + it("ModalAnnotate Escape is skipped when its command is disabled", async () => { + const modalClose = vi.fn(); + const annotateDeselect = vi.fn(); + + const manager = CommandContextManager.instance(); + const modalCtx = manager.getCommandContext(KnownContexts.Modal)!; + const annotateCtx = manager.getCommandContext(KnownContexts.ModalAnnotate)!; + + const modalCmd = modalCtx.registerCommand("fo.modal.close.test", modalClose, () => true); + modalCtx.bindKey("Escape", modalCmd.id); + + // Register with enablement returning false + const annotateCmd = annotateCtx.registerCommand( + KnownCommands.ModalAnnotateDeselect, + annotateDeselect, + () => false + ); + annotateCtx.bindKey("Escape", annotateCmd.id); + + await manager.handleKeyDown(escapeEvent()); + + expect(annotateDeselect).not.toHaveBeenCalled(); + expect(modalClose).toHaveBeenCalledOnce(); + }); + }); + it("can perform undo/redo on an inherited context", async () => { const undoFn = vi.fn(() => { return; diff --git a/app/packages/core/src/components/Modal/Sidebar/Annotate/Edit/Header.tsx b/app/packages/core/src/components/Modal/Sidebar/Annotate/Edit/Header.tsx index a7e7b348ea9..01f78831622 100644 --- a/app/packages/core/src/components/Modal/Sidebar/Annotate/Edit/Header.tsx +++ b/app/packages/core/src/components/Modal/Sidebar/Annotate/Edit/Header.tsx @@ -22,7 +22,7 @@ import { useAnnotationContext, } from "./state"; -import { KnownCommands, KnownContexts, useCommand } from "@fiftyone/commands"; +import { KnownCommands, KnownContexts, useCommand, useKeyBindings } from "@fiftyone/commands"; import useColor from "./useColor"; import useExit from "./useExit"; import { useQuickDraw } from "./useQuickDraw"; @@ -120,6 +120,16 @@ const Header = () => { scene, ]); + useKeyBindings(KnownContexts.ModalAnnotate, [ + { + commandId: KnownCommands.ModalAnnotateDeselect, + sequence: "Escape", + handler: handleExit, + label: "Deselect", + description: "Deselect the current label and return to the label list.", + }, + ]); + return (