Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
137 changes: 136 additions & 1 deletion app/packages/commands/src/context/context.manager.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<Row>
<ItemLeft style={{ columnGap: "0.5rem" }}>
Expand Down
Loading