From dcdc29721e771f448e93a3f634448cba1ed06fee Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 19:13:05 -0700 Subject: [PATCH 01/21] feat(shortcuts): make transport bindings canonical --- packages/freecut-editor/README.md | 6 + .../freecut-editor/consumer-smoke.test.tsx | 18 + packages/freecut-editor/src/index.d.ts | 97 ++ packages/freecut-editor/src/index.ts | 7 + src/config/hotkeys.test.ts | 431 ++++---- src/config/hotkeys.ts | 966 +++++++++--------- src/features/docs/pages/06-timeline.ts | 4 +- src/features/docs/pages/07-editing-tools.ts | 6 +- src/features/docs/pages/08-preview.ts | 6 + src/features/docs/pages/09-source-monitor.ts | 1 + .../docs/pages/20-keyboard-shortcuts.ts | 8 +- src/features/editor/host/contract.ts | 29 + src/features/editor/host/editor-surface.tsx | 29 +- src/features/editor/host/index.ts | 5 + .../editor/host/shortcut-settings.test.ts | 127 +++ src/features/editor/host/shortcut-settings.ts | 86 ++ .../dopesheet-editor/shortcuts.test.tsx | 148 +-- .../components/hotkey-editor-sections.ts | 339 +++--- .../components/timeline-header.test.tsx | 31 + .../timeline/components/timeline-header.tsx | 44 +- .../timeline-item/item-context-menu.test.tsx | 12 +- .../timeline-item/item-context-menu.tsx | 3 +- .../shortcuts/use-playback-shortcuts.test.tsx | 135 +++ .../hooks/shortcuts/use-playback-shortcuts.ts | 27 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 4 +- .../use-host-timeline-shortcuts.test.tsx | 19 + src/i18n/locales/partials/de/projects.json | 3 + src/i18n/locales/partials/de/timeline.json | 3 + src/i18n/locales/partials/en/projects.json | 3 + src/i18n/locales/partials/en/timeline.json | 3 + src/i18n/locales/partials/es/projects.json | 3 + src/i18n/locales/partials/es/timeline.json | 3 + src/i18n/locales/partials/fr/projects.json | 3 + src/i18n/locales/partials/fr/timeline.json | 3 + src/i18n/locales/partials/ja/projects.json | 3 + src/i18n/locales/partials/ja/timeline.json | 3 + src/i18n/locales/partials/ko/projects.json | 3 + src/i18n/locales/partials/ko/timeline.json | 3 + src/i18n/locales/partials/pt-BR/projects.json | 3 + src/i18n/locales/partials/pt-BR/timeline.json | 3 + src/i18n/locales/partials/tr/projects.json | 3 + src/i18n/locales/partials/tr/timeline.json | 3 + src/i18n/locales/partials/zh/projects.json | 3 + src/i18n/locales/partials/zh/timeline.json | 3 + 44 files changed, 1709 insertions(+), 933 deletions(-) create mode 100644 src/features/editor/host/shortcut-settings.test.ts create mode 100644 src/features/editor/host/shortcut-settings.ts create mode 100644 src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 3565a6d37..9e1d783d9 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -38,6 +38,12 @@ provider details, URLs, paths, and media bytes remain host-owned. The same 0.3.0 surface retains the host-backed caption tracks, bounded cues, caption styles, and display toggles from 0.2.0. +Hosts can also provide the optional `EditorHost.shortcuts` port. Its versioned +`HostShortcutSettings` payload carries the same override map used by FreeCut's +shortcut editor, including J/K/L transport. UI changes call `setSettings`, and +host or agent changes can flow back through `subscribe`, so embedded shortcut +configuration never becomes a UI-only setting. + This package is built from a specific FreeCut commit. To create the local consumer artifact from a clean checkout, run: diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index ee07086cd..578bbb8b2 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +/// import '@testing-library/jest-dom' import '@quantfive/freecut-editor-surface/style.css' @@ -6,7 +7,9 @@ import { render, screen, waitFor } from '@testing-library/react' import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' import { FreeCutEditorSurface, + HOTKEYS, capabilityForCommand, + createHostShortcutSettings, isHostCapabilityEnabled, type EditorHost, type EmbeddedEditorSnapshot, @@ -44,6 +47,15 @@ function fakeHost(): EditorHost { submitEdit: vi.fn(() => { throw new Error('consumer smoke does not submit an edit') }), + shortcuts: { + getSettings: () => + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }), + setSettings: vi.fn(), + }, } } @@ -89,6 +101,12 @@ describe('published FreeCut browser entry', () => { expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument() expect(await screen.findByTestId('caption-editor')).toBeInTheDocument() + expect(HOTKEYS).toMatchObject({ + SHUTTLE_REVERSE: 'j', + SHUTTLE_PAUSE: 'k', + SHUTTLE_FORWARD: 'l', + EDIT_KEYFRAME_ADD: 'shift+k', + }) expect(host.load).toHaveBeenCalledTimes(1) expect(capabilityForCommand('move_item')).toBe('timeline.move') expect(capabilityForCommand('set_caption_style')).toBe('timeline.caption') diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index 1ca774a12..29f17c7a1 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -1,5 +1,82 @@ import type { ComponentType, ReactNode } from 'react' +export type HotkeyKey = + | 'PLAY_PAUSE' + | 'SHUTTLE_REVERSE' + | 'SHUTTLE_PAUSE' + | 'SHUTTLE_FORWARD' + | 'PREVIOUS_FRAME' + | 'NEXT_FRAME' + | 'GO_TO_START' + | 'GO_TO_END' + | 'NEXT_SNAP_POINT' + | 'PREVIOUS_SNAP_POINT' + | 'SPLIT_AT_PLAYHEAD' + | 'SPLIT_AT_PLAYHEAD_ALT' + | 'JOIN_ITEMS' + | 'DELETE_SELECTED' + | 'DELETE_SELECTED_ALT' + | 'RIPPLE_DELETE' + | 'RIPPLE_DELETE_ALT' + | 'FREEZE_FRAME' + | 'LINK_AUDIO_VIDEO' + | 'UNLINK_AUDIO_VIDEO' + | 'TOGGLE_LINKED_SELECTION' + | 'NUDGE_LEFT' + | 'NUDGE_RIGHT' + | 'NUDGE_UP' + | 'NUDGE_DOWN' + | 'NUDGE_LEFT_LARGE' + | 'NUDGE_RIGHT_LARGE' + | 'NUDGE_UP_LARGE' + | 'NUDGE_DOWN_LARGE' + | 'UNDO' + | 'REDO' + | 'ZOOM_IN' + | 'ZOOM_OUT' + | 'ZOOM_TO_FIT' + | 'ZOOM_TO_100' + | 'ZOOM_TO_100_ALT' + | 'COPY' + | 'CUT' + | 'PASTE' + | 'SELECTION_TOOL' + | 'TRIM_EDIT_TOOL' + | 'RAZOR_TOOL' + | 'RATE_STRETCH_TOOL' + | 'SLIP_TOOL' + | 'SLIDE_TOOL' + | 'SAVE' + | 'EXPORT' + | 'TOGGLE_SNAP' + | 'TOGGLE_CANVAS_SNAP' + | 'OPEN_SCENE_BROWSER' + | 'WORKSPACE_EDIT' + | 'WORKSPACE_COLOR' + | 'WORKSPACE_ANIMATE' + | 'ADD_MARKER' + | 'REMOVE_MARKER' + | 'PREVIOUS_MARKER' + | 'NEXT_MARKER' + | 'CLEAR_KEYFRAMES' + | 'KEYFRAME_EDITOR_GRAPH' + | 'KEYFRAME_EDITOR_DOPESHEET' + | 'KEYFRAME_EDITOR_SPLIT' + | 'EDIT_KEYFRAME_ADD' + | 'KEYFRAME_PREVIOUS' + | 'KEYFRAME_NEXT' + | 'KEYFRAME_TOGGLE_AUTO' + | 'KEYFRAME_FIT' + | 'MARK_IN' + | 'MARK_OUT' + | 'CLEAR_IN_OUT' + | 'INSERT_EDIT' + | 'OVERWRITE_EDIT' + +export type HotkeyOverrideMap = Partial> + +export declare const HOTKEYS: Readonly> + export type EditorCapability = | 'project.navigate' | 'project.save' @@ -333,6 +410,25 @@ export interface EditorHostNavigation { back(): void } +export declare const HOST_SHORTCUTS_SCHEMA: 'freecut-host-shortcuts' +export declare const HOST_SHORTCUTS_VERSION: 1 + +export interface HostShortcutSettings { + schema: typeof HOST_SHORTCUTS_SCHEMA + version: typeof HOST_SHORTCUTS_VERSION + overrides: HotkeyOverrideMap +} + +export interface EditorShortcutPort { + getSettings(): Promise | HostShortcutSettings + setSettings(settings: HostShortcutSettings): Promise | void + subscribe?(listener: (settings: HostShortcutSettings) => void): () => void +} + +export declare function createHostShortcutSettings( + overrides?: HotkeyOverrideMap, +): HostShortcutSettings + export interface EditorHost { readonly capabilities: EditorCapabilityMap load(): Promise | EmbeddedEditorSnapshot @@ -340,6 +436,7 @@ export interface EditorHost { locator: MediaLocator, ): Promise | ResolvedMediaLocator | null submitEdit(batch: EditCommandBatch): Promise | HostEditResult + shortcuts?: EditorShortcutPort transcript?: EditorTranscriptPort navigation?: EditorHostNavigation notify?(notice: HostNotice): void diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts index ac374e68d..8bfa3bd3e 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -1,7 +1,11 @@ export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' export { EditorHostProvider } from '@/features/editor/host/context-provider' +export { HOTKEYS } from '@/config/hotkeys' +export type { HotkeyKey, HotkeyOverrideMap } from '@/config/hotkeys' export { DEFAULT_HOST_CAPABILITIES, + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, MAX_TRANSCRIPT_CURSOR_LENGTH, MAX_TRANSCRIPT_DURATION_US, @@ -11,6 +15,7 @@ export { MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, + createHostShortcutSettings, createLocalEditorHost, isHostCapabilityEnabled, } from '@/features/editor/host/contract' @@ -21,6 +26,7 @@ export type { EditorCapabilityMap, EditorHost, EditorHostNavigation, + EditorShortcutPort, EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, @@ -30,6 +36,7 @@ export type { HostEditResult, HostMediaKind, HostNotice, + HostShortcutSettings, HostTranscriptCommandAction, HostTranscriptCommandPreview, HostTranscriptCommandPreviewRequest, diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 53dec865a..28e53c697 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from 'vite-plus/test' import { HOTKEYS, HOTKEY_EXPORT_SCHEMA, @@ -15,10 +15,10 @@ import { parseHotkeyImportDocument, resolveHotkeys, sanitizeHotkeyOverrides, -} from "./hotkeys"; +} from './hotkeys' -describe("keyframe productivity hotkeys", () => { - it("provides distinct defaults for the focused editor workflow", () => { +describe('keyframe productivity hotkeys', () => { + it('provides distinct defaults for the focused editor workflow', () => { expect({ split: HOTKEYS.KEYFRAME_EDITOR_SPLIT, addInEdit: HOTKEYS.EDIT_KEYFRAME_ADD, @@ -27,297 +27,380 @@ describe("keyframe productivity hotkeys", () => { auto: HOTKEYS.KEYFRAME_TOGGLE_AUTO, fit: HOTKEYS.KEYFRAME_FIT, }).toEqual({ - split: "3", - addInEdit: "k", - previous: "alt+bracketleft", - next: "alt+bracketright", - auto: "a", - fit: "f", - }); - }); -}); + split: '3', + addInEdit: 'shift+k', + previous: 'alt+bracketleft', + next: 'alt+bracketright', + auto: 'a', + fit: 'f', + }) + }) +}) -describe("normalizeHotkeyBinding", () => { - it("orders modifiers consistently and normalizes aliases", () => { - expect(normalizeHotkeyBinding("Shift+Ctrl+ArrowLeft")).toBe( - "mod+shift+left", - ); - }); -}); +describe('transport and editing defaults', () => { + it('uses canonical J/K/L transport without conflicting with keyframe add', () => { + expect({ + reverse: HOTKEYS.SHUTTLE_REVERSE, + pause: HOTKEYS.SHUTTLE_PAUSE, + forward: HOTKEYS.SHUTTLE_FORWARD, + addKeyframe: HOTKEYS.EDIT_KEYFRAME_ADD, + splitAtPlayhead: HOTKEYS.SPLIT_AT_PLAYHEAD, + }).toEqual({ + reverse: 'j', + pause: 'k', + forward: 'l', + addKeyframe: 'shift+k', + splitAtPlayhead: 'shift+c', + }) + }) +}) -describe("formatHotkeyBinding", () => { - it("formats modifier labels for mac", () => { - expect(formatHotkeyBinding("mod+alt+k", "MacIntel")).toBe( - "Cmd + Option + K", - ); - }); +describe('normalizeHotkeyBinding', () => { + it('orders modifiers consistently and normalizes aliases', () => { + expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('mod+shift+left') + }) +}) - it("formats punctuation bindings for windows", () => { - expect(formatHotkeyBinding("mod+shift+comma", "Win32")).toBe( - "Ctrl + Shift + ,", - ); - }); -}); +describe('formatHotkeyBinding', () => { + it('formats modifier labels for mac', () => { + expect(formatHotkeyBinding('mod+alt+k', 'MacIntel')).toBe('Cmd + Option + K') + }) -describe("getBrowserHostileHotkey", () => { - it("detects browser-reserved shortcuts after normalization", () => { - expect(getBrowserHostileHotkey("Ctrl+E")).toEqual({ - binding: "mod+e", - browserAction: "Focus search or address bar in some browsers", - }); - }); + it('formats punctuation bindings for windows', () => { + expect(formatHotkeyBinding('mod+shift+comma', 'Win32')).toBe('Ctrl + Shift + ,') + }) +}) - it("returns null for browser-safe shortcuts", () => { - expect(getBrowserHostileHotkey("shift+j")).toBeNull(); - }); +describe('getBrowserHostileHotkey', () => { + it('detects browser-reserved shortcuts after normalization', () => { + expect(getBrowserHostileHotkey('Ctrl+E')).toEqual({ + binding: 'mod+e', + browserAction: 'Focus search or address bar in some browsers', + }) + }) - it("flags browser zoom shortcuts as hostile", () => { - expect(getBrowserHostileHotkey("Ctrl+=")).toEqual({ - binding: "mod+equal", - browserAction: "Browser zoom in", - }); - expect(getBrowserHostileHotkey("Ctrl+-")).toEqual({ - binding: "mod+minus", - browserAction: "Browser zoom out", - }); - expect(getBrowserHostileHotkey("Ctrl+0")).toEqual({ - binding: "mod+0", - browserAction: "Reset browser zoom", - }); - }); + it('returns null for browser-safe shortcuts', () => { + expect(getBrowserHostileHotkey('shift+j')).toBeNull() + }) - it("flags Ctrl+Shift+L as hostile and leaves Shift+L available", () => { - expect(getBrowserHostileHotkey("Ctrl+Shift+L")).toEqual({ - binding: "mod+shift+l", - browserAction: "Focus address bar or search in some browsers", - }); - expect(getBrowserHostileHotkey("Shift+L")).toBeNull(); - }); -}); + it('flags browser zoom shortcuts as hostile', () => { + expect(getBrowserHostileHotkey('Ctrl+=')).toEqual({ + binding: 'mod+equal', + browserAction: 'Browser zoom in', + }) + expect(getBrowserHostileHotkey('Ctrl+-')).toEqual({ + binding: 'mod+minus', + browserAction: 'Browser zoom out', + }) + expect(getBrowserHostileHotkey('Ctrl+0')).toEqual({ + binding: 'mod+0', + browserAction: 'Reset browser zoom', + }) + }) -describe("getHotkeyBindingFromEventData", () => { - it("captures letter bindings with modifiers", () => { + it('flags Ctrl+Shift+L as hostile and leaves Shift+L available', () => { + expect(getBrowserHostileHotkey('Ctrl+Shift+L')).toEqual({ + binding: 'mod+shift+l', + browserAction: 'Focus address bar or search in some browsers', + }) + expect(getBrowserHostileHotkey('Shift+L')).toBeNull() + }) +}) + +describe('getHotkeyBindingFromEventData', () => { + it('captures letter bindings with modifiers', () => { expect( getHotkeyBindingFromEventData({ - code: "KeyA", - key: "a", + code: 'KeyA', + key: 'a', ctrlKey: true, shiftKey: true, }), - ).toBe("mod+shift+a"); - }); + ).toBe('mod+shift+a') + }) - it("captures modifier-only previews before a final key lands", () => { + it('captures modifier-only previews before a final key lands', () => { expect( getHotkeyBindingFromEventData({ - code: "ShiftLeft", - key: "Shift", + code: 'ShiftLeft', + key: 'Shift', shiftKey: true, }), - ).toBe("shift"); - }); + ).toBe('shift') + }) - it("uses event.code for shifted punctuation keys", () => { + it('uses event.code for shifted punctuation keys', () => { expect( getHotkeyPrimaryTokenFromEventData({ - code: "Comma", - key: "<", + code: 'Comma', + key: '<', shiftKey: true, }), - ).toBe("comma"); - }); -}); + ).toBe('comma') + }) +}) -describe("findHotkeyConflicts", () => { - it("returns other bindings using the same normalized shortcut", () => { +describe('findHotkeyConflicts', () => { + it('returns other bindings using the same normalized shortcut', () => { const bindings = resolveHotkeys({ - SELECTION_TOOL: "c", - }); + SELECTION_TOOL: 'c', + }) - expect(findHotkeyConflicts(bindings, "c", "SELECTION_TOOL")).toEqual([ - "RAZOR_TOOL", - ]); - }); -}); + expect(findHotkeyConflicts(bindings, 'c', 'SELECTION_TOOL')).toEqual(['RAZOR_TOOL']) + }) +}) -describe("sanitizeHotkeyOverrides", () => { - it("keeps only supported commands with normalized non-default bindings", () => { +describe('sanitizeHotkeyOverrides', () => { + it('keeps only supported commands with normalized non-default bindings', () => { expect( sanitizeHotkeyOverrides({ - PLAY_PAUSE: " Shift+Space ", - EXPORT: "Ctrl+E", - UNKNOWN_COMMAND: "q", - DELETE_SELECTED: "", + PLAY_PAUSE: ' Shift+Space ', + EXPORT: 'Ctrl+E', + UNKNOWN_COMMAND: 'q', + DELETE_SELECTED: '', }), ).toEqual({ - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - DELETE_SELECTED: "", - }); - }); -}); + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + DELETE_SELECTED: '', + }) + }) -describe("createHotkeyExportDocument", () => { - it("creates a versioned export with command metadata and sanitized overrides", () => { + it('migrates the legacy split-at-cursor command id', () => { + expect( + sanitizeHotkeyOverrides({ + SPLIT_AT_CURSOR: 'mod+shift+c', + }), + ).toEqual({ + SPLIT_AT_PLAYHEAD: 'mod+shift+c', + }) + }) +}) + +describe('createHotkeyExportDocument', () => { + it('creates a versioned export with command metadata and sanitized overrides', () => { const exportDocument = createHotkeyExportDocument({ - PLAY_PAUSE: "Shift+Space", - EXPORT: "Ctrl+E", - }); + PLAY_PAUSE: 'Shift+Space', + EXPORT: 'Ctrl+E', + }) - expect(exportDocument.schema).toBe(HOTKEY_EXPORT_SCHEMA); - expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION); + expect(exportDocument.schema).toBe(HOTKEY_EXPORT_SCHEMA) + expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION) expect(exportDocument.overrides).toEqual({ - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - }); + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "PLAY_PAUSE", - label: "Play/Pause", - binding: "shift+space", - defaultBinding: "space", + id: 'PLAY_PAUSE', + label: 'Play/Pause', + binding: 'shift+space', + defaultBinding: 'space', isCustom: true, }), - ); + ) + expect(exportDocument.commands).toContainEqual( + expect.objectContaining({ + id: 'SHUTTLE_PAUSE', + binding: 'k', + defaultBinding: 'k', + }), + ) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "EXPORT", - binding: "mod+e", - defaultBinding: "mod+shift+e", + id: 'EXPORT', + binding: 'mod+e', + defaultBinding: 'mod+shift+e', isCustom: true, }), - ); - }); + ) + }) - it("exports explicitly unassigned commands as custom blank bindings", () => { + it('exports explicitly unassigned commands as custom blank bindings', () => { const exportDocument = createHotkeyExportDocument({ - DELETE_SELECTED: "", - }); + DELETE_SELECTED: '', + }) expect(exportDocument.overrides).toEqual({ - DELETE_SELECTED: "", - }); + DELETE_SELECTED: '', + }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ - id: "DELETE_SELECTED", - binding: "", - defaultBinding: "delete", + id: 'DELETE_SELECTED', + binding: '', + defaultBinding: 'delete', isCustom: true, }), - ); - }); -}); + ) + }) +}) -describe("parseHotkeyImportDocument", () => { - it("imports versioned override payloads and ignores unknown commands", () => { +describe('parseHotkeyImportDocument', () => { + it('imports versioned override payloads and ignores unknown commands', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, overrides: { - PLAY_PAUSE: "Shift+Space", - UNKNOWN_COMMAND: "q", + PLAY_PAUSE: 'Shift+Space', + UNKNOWN_COMMAND: 'q', }, }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", + PLAY_PAUSE: 'shift+space', }, importedCommandCount: 1, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("falls back to command entries when overrides are missing", () => { + it('falls back to command entries when overrides are missing', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ - { id: "PLAY_PAUSE", binding: "Shift+Space" }, - { id: "EXPORT", binding: "Ctrl+E" }, - { id: "UNKNOWN_COMMAND", binding: "q" }, + { id: 'PLAY_PAUSE', binding: 'Shift+Space' }, + { id: 'EXPORT', binding: 'Ctrl+E' }, + { id: 'UNKNOWN_COMMAND', binding: 'q' }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', }, importedCommandCount: 2, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("remaps renamed commands from exported metadata when ids no longer match", () => { + it('remaps renamed commands from exported metadata when ids no longer match', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ { - id: "PLAYBACK_TOGGLE_OLD", - label: "Play/Pause", - defaultBinding: "space", - binding: "Shift+Space", + id: 'PLAYBACK_TOGGLE_OLD', + label: 'Play/Pause', + defaultBinding: 'space', + binding: 'Shift+Space', }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", + PLAY_PAUSE: 'shift+space', }, importedCommandCount: 1, ignoredCommandCount: 0, remappedCommandCount: 1, sourceVersion: 1, - }); - }); + }) + }) - it("imports explicitly unassigned shortcuts", () => { + it('imports explicitly unassigned shortcuts', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, version: 1, commands: [ - { id: "PLAY_PAUSE", binding: "" }, - { id: "EXPORT", binding: "Ctrl+E" }, + { id: 'PLAY_PAUSE', binding: '' }, + { id: 'EXPORT', binding: 'Ctrl+E' }, ], }), ).toEqual({ overrides: { - PLAY_PAUSE: "", - EXPORT: "mod+e", + PLAY_PAUSE: '', + EXPORT: 'mod+e', }, importedCommandCount: 2, ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: 1, - }); - }); + }) + }) - it("supports plain legacy key-binding maps", () => { + it('supports plain legacy key-binding maps', () => { expect( parseHotkeyImportDocument({ - PLAY_PAUSE: "Shift+Space", - EXPORT: "Ctrl+E", - DELETE_SELECTED: "", - UNKNOWN_COMMAND: "q", + PLAY_PAUSE: 'Shift+Space', + EXPORT: 'Ctrl+E', + DELETE_SELECTED: '', + UNKNOWN_COMMAND: 'q', }), ).toEqual({ overrides: { - PLAY_PAUSE: "shift+space", - EXPORT: "mod+e", - DELETE_SELECTED: "", + PLAY_PAUSE: 'shift+space', + EXPORT: 'mod+e', + DELETE_SELECTED: '', }, importedCommandCount: 3, ignoredCommandCount: 1, remappedCommandCount: 0, sourceVersion: null, - }); - }); -}); + }) + }) + + it('imports the renamed split command from a v1 preset', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 1, + overrides: { + SPLIT_AT_CURSOR: 'mod+shift+c', + }, + }), + ).toEqual({ + overrides: { + SPLIT_AT_PLAYHEAD: 'mod+shift+c', + }, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 1, + sourceVersion: 1, + }) + }) + + it('migrates the v1 plain-K keyframe default without recreating the transport conflict', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 1, + commands: [{ id: 'EDIT_KEYFRAME_ADD', binding: 'k', defaultBinding: 'k' }], + }), + ).toEqual({ + overrides: {}, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 0, + sourceVersion: 1, + }) + }) + + it('preserves an intentional plain-K keyframe override in a v2 preset', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 2, + overrides: { + EDIT_KEYFRAME_ADD: 'k', + }, + }), + ).toEqual({ + overrides: { + EDIT_KEYFRAME_ADD: 'k', + }, + importedCommandCount: 1, + ignoredCommandCount: 0, + remappedCommandCount: 0, + sourceVersion: 2, + }) + }) +}) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 050724cd4..9cc74b8bd 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -7,273 +7,278 @@ export const HOTKEYS = { // Playback controls - PLAY_PAUSE: "space", - PREVIOUS_FRAME: "left", - NEXT_FRAME: "right", - GO_TO_START: "home", - GO_TO_END: "end", - NEXT_SNAP_POINT: "down", - PREVIOUS_SNAP_POINT: "up", + PLAY_PAUSE: 'space', + SHUTTLE_REVERSE: 'j', + SHUTTLE_PAUSE: 'k', + SHUTTLE_FORWARD: 'l', + PREVIOUS_FRAME: 'left', + NEXT_FRAME: 'right', + GO_TO_START: 'home', + GO_TO_END: 'end', + NEXT_SNAP_POINT: 'down', + PREVIOUS_SNAP_POINT: 'up', // Timeline editing - SPLIT_AT_PLAYHEAD_ALT: "alt+c", - JOIN_ITEMS: "shift+j", - DELETE_SELECTED: "delete", - DELETE_SELECTED_ALT: "backspace", - RIPPLE_DELETE: "mod+delete", - RIPPLE_DELETE_ALT: "mod+backspace", - FREEZE_FRAME: "shift+f", - LINK_AUDIO_VIDEO: "mod+alt+l", - UNLINK_AUDIO_VIDEO: "alt+shift+l", - TOGGLE_LINKED_SELECTION: "shift+l", - NUDGE_LEFT: "shift+left", - NUDGE_RIGHT: "shift+right", - NUDGE_UP: "shift+up", - NUDGE_DOWN: "shift+down", - NUDGE_LEFT_LARGE: "mod+shift+left", - NUDGE_RIGHT_LARGE: "mod+shift+right", - NUDGE_UP_LARGE: "mod+shift+up", - NUDGE_DOWN_LARGE: "mod+shift+down", + SPLIT_AT_PLAYHEAD_ALT: 'alt+c', + JOIN_ITEMS: 'shift+j', + DELETE_SELECTED: 'delete', + DELETE_SELECTED_ALT: 'backspace', + RIPPLE_DELETE: 'mod+delete', + RIPPLE_DELETE_ALT: 'mod+backspace', + FREEZE_FRAME: 'shift+f', + LINK_AUDIO_VIDEO: 'mod+alt+l', + UNLINK_AUDIO_VIDEO: 'alt+shift+l', + TOGGLE_LINKED_SELECTION: 'shift+l', + NUDGE_LEFT: 'shift+left', + NUDGE_RIGHT: 'shift+right', + NUDGE_UP: 'shift+up', + NUDGE_DOWN: 'shift+down', + NUDGE_LEFT_LARGE: 'mod+shift+left', + NUDGE_RIGHT_LARGE: 'mod+shift+right', + NUDGE_UP_LARGE: 'mod+shift+up', + NUDGE_DOWN_LARGE: 'mod+shift+down', // History - UNDO: "mod+z", - REDO: "mod+shift+z", + UNDO: 'mod+z', + REDO: 'mod+shift+z', // Zoom - ZOOM_IN: "mod+equal", - ZOOM_OUT: "mod+minus", - ZOOM_TO_FIT: "backslash", - ZOOM_TO_100: "shift+backslash", - ZOOM_TO_100_ALT: "mod+0", + ZOOM_IN: 'mod+equal', + ZOOM_OUT: 'mod+minus', + ZOOM_TO_FIT: 'backslash', + ZOOM_TO_100: 'shift+backslash', + ZOOM_TO_100_ALT: 'mod+0', // Clipboard - COPY: "mod+c", - CUT: "mod+x", - PASTE: "mod+v", + COPY: 'mod+c', + CUT: 'mod+x', + PASTE: 'mod+v', // Tools - SELECTION_TOOL: "v", - TRIM_EDIT_TOOL: "t", - RAZOR_TOOL: "c", - SPLIT_AT_CURSOR: "shift+c", - RATE_STRETCH_TOOL: "r", - SLIP_TOOL: "y", - SLIDE_TOOL: "u", + SELECTION_TOOL: 'v', + TRIM_EDIT_TOOL: 't', + RAZOR_TOOL: 'c', + SPLIT_AT_PLAYHEAD: 'shift+c', + RATE_STRETCH_TOOL: 'r', + SLIP_TOOL: 'y', + SLIDE_TOOL: 'u', // Project - SAVE: "mod+s", - EXPORT: "mod+shift+e", + SAVE: 'mod+s', + EXPORT: 'mod+shift+e', // UI - TOGGLE_SNAP: "s", - TOGGLE_CANVAS_SNAP: "shift+s", - OPEN_SCENE_BROWSER: "mod+shift+f", - WORKSPACE_EDIT: "alt+1", - WORKSPACE_COLOR: "alt+2", - WORKSPACE_ANIMATE: "alt+3", + TOGGLE_SNAP: 's', + TOGGLE_CANVAS_SNAP: 'shift+s', + OPEN_SCENE_BROWSER: 'mod+shift+f', + WORKSPACE_EDIT: 'alt+1', + WORKSPACE_COLOR: 'alt+2', + WORKSPACE_ANIMATE: 'alt+3', // Markers - ADD_MARKER: "m", - REMOVE_MARKER: "shift+m", - PREVIOUS_MARKER: "bracketleft", - NEXT_MARKER: "bracketright", + ADD_MARKER: 'm', + REMOVE_MARKER: 'shift+m', + PREVIOUS_MARKER: 'bracketleft', + NEXT_MARKER: 'bracketright', // Keyframes - CLEAR_KEYFRAMES: "shift+a", - KEYFRAME_EDITOR_GRAPH: "1", - KEYFRAME_EDITOR_DOPESHEET: "2", - KEYFRAME_EDITOR_SPLIT: "3", - EDIT_KEYFRAME_ADD: "k", - KEYFRAME_PREVIOUS: "alt+bracketleft", - KEYFRAME_NEXT: "alt+bracketright", - KEYFRAME_TOGGLE_AUTO: "a", - KEYFRAME_FIT: "f", + CLEAR_KEYFRAMES: 'shift+a', + KEYFRAME_EDITOR_GRAPH: '1', + KEYFRAME_EDITOR_DOPESHEET: '2', + KEYFRAME_EDITOR_SPLIT: '3', + EDIT_KEYFRAME_ADD: 'shift+k', + KEYFRAME_PREVIOUS: 'alt+bracketleft', + KEYFRAME_NEXT: 'alt+bracketright', + KEYFRAME_TOGGLE_AUTO: 'a', + KEYFRAME_FIT: 'f', // Source Monitor - MARK_IN: "i", - MARK_OUT: "o", - CLEAR_IN_OUT: "alt+x", - INSERT_EDIT: "comma", - OVERWRITE_EDIT: "period", -} as const; + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', +} as const -export type HotkeyKey = keyof typeof HOTKEYS; -export type HotkeyBindingMap = Record; -export type HotkeyOverrideMap = Partial>; -type HotkeyPlatform = "mac" | "windows"; +export type HotkeyKey = keyof typeof HOTKEYS +export type HotkeyBindingMap = Record +export type HotkeyOverrideMap = Partial> +type HotkeyPlatform = 'mac' | 'windows' -export const HOTKEY_EXPORT_SCHEMA = "freecut-hotkeys"; -export const HOTKEY_EXPORT_VERSION = 1; +export const HOTKEY_EXPORT_SCHEMA = 'freecut-hotkeys' +export const HOTKEY_EXPORT_VERSION = 2 export interface HotkeyExportCommand { - id: HotkeyKey; - label: string; - binding: string; - defaultBinding: string; - isCustom: boolean; + id: HotkeyKey + label: string + binding: string + defaultBinding: string + isCustom: boolean } export interface HotkeyExportDocument { - schema: typeof HOTKEY_EXPORT_SCHEMA; - version: typeof HOTKEY_EXPORT_VERSION; - exportedAt: string; - commands: HotkeyExportCommand[]; - overrides: HotkeyOverrideMap; + schema: typeof HOTKEY_EXPORT_SCHEMA + version: typeof HOTKEY_EXPORT_VERSION + exportedAt: string + commands: HotkeyExportCommand[] + overrides: HotkeyOverrideMap } interface HotkeyImportCommand { - id?: string; - key?: string; - label?: string; - binding?: string; - shortcut?: string; - defaultBinding?: string; + id?: string + key?: string + label?: string + binding?: string + shortcut?: string + defaultBinding?: string } export interface HotkeyImportResult { - overrides: HotkeyOverrideMap; - importedCommandCount: number; - ignoredCommandCount: number; - remappedCommandCount: number; - sourceVersion: number | null; + overrides: HotkeyOverrideMap + importedCommandCount: number + ignoredCommandCount: number + remappedCommandCount: number + sourceVersion: number | null } export interface BrowserHostileHotkey { - binding: string; - browserAction: string; + binding: string + browserAction: string } interface HotkeyCommandLookup { - byLabel: Map; - byDefaultBinding: Map; + byLabel: Map + byDefaultBinding: Map } -const HOTKEY_MODIFIERS = ["mod", "alt", "shift"] as const; -const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS); +const HOTKEY_MODIFIERS = ['mod', 'alt', 'shift'] as const +const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS) const HOTKEY_MODIFIER_ORDER = new Map( HOTKEY_MODIFIERS.map((token, index) => [token, index]), -); +) const HOTKEY_TOKEN_ALIASES: Record = { - cmd: "mod", - command: "mod", - ctrl: "mod", - control: "mod", - option: "alt", - return: "enter", - esc: "escape", - del: "delete", - "=": "equal", - equals: "equal", - "-": "minus", - arrowleft: "left", - arrowright: "right", - arrowup: "up", - arrowdown: "down", -}; + cmd: 'mod', + command: 'mod', + ctrl: 'mod', + control: 'mod', + option: 'alt', + return: 'enter', + esc: 'escape', + del: 'delete', + '=': 'equal', + equals: 'equal', + '-': 'minus', + arrowleft: 'left', + arrowright: 'right', + arrowup: 'up', + arrowdown: 'down', +} const HOTKEY_KEY_LABELS: Record = { - space: "Space", - comma: ",", - period: ".", - bracketleft: "[", - bracketright: "]", - minus: "-", - equal: "=", - slash: "/", - backslash: "\\", - semicolon: ";", + space: 'Space', + comma: ',', + period: '.', + bracketleft: '[', + bracketright: ']', + minus: '-', + equal: '=', + slash: '/', + backslash: '\\', + semicolon: ';', quote: "'", - backquote: "`", - left: "Left", - right: "Right", - up: "Up", - down: "Down", - home: "Home", - end: "End", - delete: "Delete", - backspace: "Backspace", - escape: "Esc", - tab: "Tab", - enter: "Enter", -}; + backquote: '`', + left: 'Left', + right: 'Right', + up: 'Up', + down: 'Down', + home: 'Home', + end: 'End', + delete: 'Delete', + backspace: 'Backspace', + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', +} const HOTKEY_CODE_TOKEN_MAP: Record = { - Space: "space", - Comma: "comma", - Period: "period", - BracketLeft: "bracketleft", - BracketRight: "bracketright", - Minus: "minus", - Equal: "equal", - Slash: "slash", - Backslash: "backslash", - Semicolon: "semicolon", - Quote: "quote", - Backquote: "backquote", - ArrowLeft: "left", - ArrowRight: "right", - ArrowUp: "up", - ArrowDown: "down", - Home: "home", - End: "end", - Delete: "delete", - Backspace: "backspace", - Escape: "escape", - Tab: "tab", - Enter: "enter", -}; - -const HOTKEY_COMMAND_ALIASES: Partial> = {}; + Space: 'space', + Comma: 'comma', + Period: 'period', + BracketLeft: 'bracketleft', + BracketRight: 'bracketright', + Minus: 'minus', + Equal: 'equal', + Slash: 'slash', + Backslash: 'backslash', + Semicolon: 'semicolon', + Quote: 'quote', + Backquote: 'backquote', + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + ArrowDown: 'down', + Home: 'home', + End: 'end', + Delete: 'delete', + Backspace: 'backspace', + Escape: 'escape', + Tab: 'tab', + Enter: 'enter', +} + +const HOTKEY_COMMAND_ALIASES: Partial> = { + SPLIT_AT_CURSOR: 'SPLIT_AT_PLAYHEAD', +} const BROWSER_HOSTILE_HOTKEYS: readonly BrowserHostileHotkey[] = [ - { binding: "alt+left", browserAction: "Back navigation" }, - { binding: "alt+right", browserAction: "Forward navigation" }, - { binding: "f5", browserAction: "Reload page" }, - { binding: "mod+r", browserAction: "Reload page" }, - { binding: "mod+shift+r", browserAction: "Hard reload page" }, - { binding: "mod+t", browserAction: "New tab" }, - { binding: "mod+shift+t", browserAction: "Reopen closed tab" }, - { binding: "mod+w", browserAction: "Close tab" }, - { binding: "mod+n", browserAction: "New window" }, - { binding: "mod+shift+n", browserAction: "New private window" }, - { binding: "mod+l", browserAction: "Focus address bar" }, + { binding: 'alt+left', browserAction: 'Back navigation' }, + { binding: 'alt+right', browserAction: 'Forward navigation' }, + { binding: 'f5', browserAction: 'Reload page' }, + { binding: 'mod+r', browserAction: 'Reload page' }, + { binding: 'mod+shift+r', browserAction: 'Hard reload page' }, + { binding: 'mod+t', browserAction: 'New tab' }, + { binding: 'mod+shift+t', browserAction: 'Reopen closed tab' }, + { binding: 'mod+w', browserAction: 'Close tab' }, + { binding: 'mod+n', browserAction: 'New window' }, + { binding: 'mod+shift+n', browserAction: 'New private window' }, + { binding: 'mod+l', browserAction: 'Focus address bar' }, { - binding: "mod+shift+l", - browserAction: "Focus address bar or search in some browsers", + binding: 'mod+shift+l', + browserAction: 'Focus address bar or search in some browsers', }, - { binding: "mod+d", browserAction: "Bookmark page or focus address bar" }, + { binding: 'mod+d', browserAction: 'Bookmark page or focus address bar' }, { - binding: "mod+e", - browserAction: "Focus search or address bar in some browsers", + binding: 'mod+e', + browserAction: 'Focus search or address bar in some browsers', }, - { binding: "mod+p", browserAction: "Print page" }, - { binding: "mod+f", browserAction: "Find in page" }, - { binding: "mod+equal", browserAction: "Browser zoom in" }, - { binding: "mod+minus", browserAction: "Browser zoom out" }, - { binding: "mod+0", browserAction: "Reset browser zoom" }, - { binding: "mod+1", browserAction: "Switch to tab 1" }, - { binding: "mod+2", browserAction: "Switch to tab 2" }, - { binding: "mod+3", browserAction: "Switch to tab 3" }, - { binding: "mod+4", browserAction: "Switch to tab 4" }, - { binding: "mod+5", browserAction: "Switch to tab 5" }, - { binding: "mod+6", browserAction: "Switch to tab 6" }, - { binding: "mod+7", browserAction: "Switch to tab 7" }, - { binding: "mod+8", browserAction: "Switch to tab 8" }, - { binding: "mod+9", browserAction: "Switch to last tab" }, -] as const; + { binding: 'mod+p', browserAction: 'Print page' }, + { binding: 'mod+f', browserAction: 'Find in page' }, + { binding: 'mod+equal', browserAction: 'Browser zoom in' }, + { binding: 'mod+minus', browserAction: 'Browser zoom out' }, + { binding: 'mod+0', browserAction: 'Reset browser zoom' }, + { binding: 'mod+1', browserAction: 'Switch to tab 1' }, + { binding: 'mod+2', browserAction: 'Switch to tab 2' }, + { binding: 'mod+3', browserAction: 'Switch to tab 3' }, + { binding: 'mod+4', browserAction: 'Switch to tab 4' }, + { binding: 'mod+5', browserAction: 'Switch to tab 5' }, + { binding: 'mod+6', browserAction: 'Switch to tab 6' }, + { binding: 'mod+7', browserAction: 'Switch to tab 7' }, + { binding: 'mod+8', browserAction: 'Switch to tab 8' }, + { binding: 'mod+9', browserAction: 'Switch to last tab' }, +] as const const BROWSER_HOSTILE_HOTKEY_MAP = new Map( BROWSER_HOSTILE_HOTKEYS.map((entry) => [entry.binding, entry]), -); +) export interface HotkeyEventData { - key?: string; - code?: string; - ctrlKey?: boolean; - metaKey?: boolean; - altKey?: boolean; - shiftKey?: boolean; + key?: string + code?: string + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean + shiftKey?: boolean } /** @@ -282,426 +287,401 @@ export interface HotkeyEventData { */ export const HOTKEY_DESCRIPTIONS: Record = { // Playback - PLAY_PAUSE: "Play/Pause", - PREVIOUS_FRAME: "Previous frame", - NEXT_FRAME: "Next frame", - GO_TO_START: "Go to start", - GO_TO_END: "Go to end", - NEXT_SNAP_POINT: "Next snap point", - PREVIOUS_SNAP_POINT: "Previous snap point", + PLAY_PAUSE: 'Play/Pause', + SHUTTLE_REVERSE: 'Shuttle reverse', + SHUTTLE_PAUSE: 'Pause transport', + SHUTTLE_FORWARD: 'Shuttle forward', + PREVIOUS_FRAME: 'Previous frame', + NEXT_FRAME: 'Next frame', + GO_TO_START: 'Go to start', + GO_TO_END: 'Go to end', + NEXT_SNAP_POINT: 'Next snap point', + PREVIOUS_SNAP_POINT: 'Previous snap point', // Timeline editing - SPLIT_AT_PLAYHEAD_ALT: "Split at playhead", - JOIN_ITEMS: "Join selected clips", - DELETE_SELECTED: "Delete selected items", - DELETE_SELECTED_ALT: "Delete selected items (alternative)", - RIPPLE_DELETE: "Ripple delete selected items", - RIPPLE_DELETE_ALT: "Ripple delete selected items (alternative)", - FREEZE_FRAME: "Insert freeze frame at playhead", - LINK_AUDIO_VIDEO: "Link selected clips", - UNLINK_AUDIO_VIDEO: "Unlink selected clips", - TOGGLE_LINKED_SELECTION: "Toggle linked selection", - NUDGE_LEFT: "Nudge selected visual items left (1px)", - NUDGE_RIGHT: "Nudge selected visual items right (1px)", - NUDGE_UP: "Nudge selected visual items up (1px)", - NUDGE_DOWN: "Nudge selected visual items down (1px)", - NUDGE_LEFT_LARGE: "Nudge selected visual items left (10px)", - NUDGE_RIGHT_LARGE: "Nudge selected visual items right (10px)", - NUDGE_UP_LARGE: "Nudge selected visual items up (10px)", - NUDGE_DOWN_LARGE: "Nudge selected visual items down (10px)", + SPLIT_AT_PLAYHEAD_ALT: 'Split at playhead', + JOIN_ITEMS: 'Join selected clips', + DELETE_SELECTED: 'Delete selected items', + DELETE_SELECTED_ALT: 'Delete selected items (alternative)', + RIPPLE_DELETE: 'Ripple delete selected items', + RIPPLE_DELETE_ALT: 'Ripple delete selected items (alternative)', + FREEZE_FRAME: 'Insert freeze frame at playhead', + LINK_AUDIO_VIDEO: 'Link selected clips', + UNLINK_AUDIO_VIDEO: 'Unlink selected clips', + TOGGLE_LINKED_SELECTION: 'Toggle linked selection', + NUDGE_LEFT: 'Nudge selected visual items left (1px)', + NUDGE_RIGHT: 'Nudge selected visual items right (1px)', + NUDGE_UP: 'Nudge selected visual items up (1px)', + NUDGE_DOWN: 'Nudge selected visual items down (1px)', + NUDGE_LEFT_LARGE: 'Nudge selected visual items left (10px)', + NUDGE_RIGHT_LARGE: 'Nudge selected visual items right (10px)', + NUDGE_UP_LARGE: 'Nudge selected visual items up (10px)', + NUDGE_DOWN_LARGE: 'Nudge selected visual items down (10px)', // History - UNDO: "Undo", - REDO: "Redo", + UNDO: 'Undo', + REDO: 'Redo', // Zoom - ZOOM_IN: "Zoom in timeline", - ZOOM_OUT: "Zoom out timeline", - ZOOM_TO_FIT: "Zoom to fit all content", - ZOOM_TO_100: "Zoom to 100% at cursor or playhead", - ZOOM_TO_100_ALT: "Zoom to 100% at cursor or playhead (alternative)", + ZOOM_IN: 'Zoom in timeline', + ZOOM_OUT: 'Zoom out timeline', + ZOOM_TO_FIT: 'Zoom to fit all content', + ZOOM_TO_100: 'Zoom to 100% at cursor or playhead', + ZOOM_TO_100_ALT: 'Zoom to 100% at cursor or playhead (alternative)', // Clipboard - COPY: "Copy selected items or keyframes", - CUT: "Cut selected items or keyframes", - PASTE: "Paste items or keyframes", + COPY: 'Copy selected items or keyframes', + CUT: 'Cut selected items or keyframes', + PASTE: 'Paste items or keyframes', // Tools - SELECTION_TOOL: "Selection tool", - TRIM_EDIT_TOOL: "Trim edit tool", - RAZOR_TOOL: "Razor tool", - SPLIT_AT_CURSOR: "Split at cursor", - RATE_STRETCH_TOOL: "Rate stretch tool", - SLIP_TOOL: "Slip tool", - SLIDE_TOOL: "Slide tool", + SELECTION_TOOL: 'Selection tool', + TRIM_EDIT_TOOL: 'Trim edit tool', + RAZOR_TOOL: 'Razor tool', + SPLIT_AT_PLAYHEAD: 'Split at playhead', + RATE_STRETCH_TOOL: 'Rate stretch tool', + SLIP_TOOL: 'Slip tool', + SLIDE_TOOL: 'Slide tool', // Project - SAVE: "Save project", - EXPORT: "Export video", + SAVE: 'Save project', + EXPORT: 'Export video', // UI - TOGGLE_SNAP: "Toggle snap", - TOGGLE_CANVAS_SNAP: "Toggle canvas (gizmo) snap", - OPEN_SCENE_BROWSER: "Open Scene Browser (search AI captions)", - WORKSPACE_EDIT: "Switch to Edit workspace", - WORKSPACE_COLOR: "Switch to Color workspace", - WORKSPACE_ANIMATE: "Switch to Motion workspace", + TOGGLE_SNAP: 'Toggle snap', + TOGGLE_CANVAS_SNAP: 'Toggle canvas (gizmo) snap', + OPEN_SCENE_BROWSER: 'Open Scene Browser (search AI captions)', + WORKSPACE_EDIT: 'Switch to Edit workspace', + WORKSPACE_COLOR: 'Switch to Color workspace', + WORKSPACE_ANIMATE: 'Switch to Motion workspace', // Markers - ADD_MARKER: "Add marker at playhead", - REMOVE_MARKER: "Remove selected marker", - PREVIOUS_MARKER: "Jump to previous marker", - NEXT_MARKER: "Jump to next marker", + ADD_MARKER: 'Add marker at playhead', + REMOVE_MARKER: 'Remove selected marker', + PREVIOUS_MARKER: 'Jump to previous marker', + NEXT_MARKER: 'Jump to next marker', // Keyframes - CLEAR_KEYFRAMES: "Clear all keyframes from selected items", - KEYFRAME_EDITOR_GRAPH: "Switch keyframe editor to graph view", - KEYFRAME_EDITOR_DOPESHEET: "Switch keyframe editor to dopesheet view", - KEYFRAME_EDITOR_SPLIT: "Switch keyframe editor to split view", - EDIT_KEYFRAME_ADD: "Add keyframe at playhead for selected Edit layer", - KEYFRAME_PREVIOUS: "Jump to previous property keyframe", - KEYFRAME_NEXT: "Jump to next property keyframe", - KEYFRAME_TOGGLE_AUTO: "Toggle auto-key for active property", - KEYFRAME_FIT: "Fit selected keyframes in view", + CLEAR_KEYFRAMES: 'Clear all keyframes from selected items', + KEYFRAME_EDITOR_GRAPH: 'Switch keyframe editor to graph view', + KEYFRAME_EDITOR_DOPESHEET: 'Switch keyframe editor to dopesheet view', + KEYFRAME_EDITOR_SPLIT: 'Switch keyframe editor to split view', + EDIT_KEYFRAME_ADD: 'Add keyframe at playhead for selected Edit layer', + KEYFRAME_PREVIOUS: 'Jump to previous property keyframe', + KEYFRAME_NEXT: 'Jump to next property keyframe', + KEYFRAME_TOGGLE_AUTO: 'Toggle auto-key for active property', + KEYFRAME_FIT: 'Fit selected keyframes in view', // Source Monitor - MARK_IN: "Mark In point", - MARK_OUT: "Mark Out point", - CLEAR_IN_OUT: "Clear In/Out points", - INSERT_EDIT: "Insert edit", - OVERWRITE_EDIT: "Overwrite edit", -}; + MARK_IN: 'Mark In point', + MARK_OUT: 'Mark Out point', + CLEAR_IN_OUT: 'Clear In/Out points', + INSERT_EDIT: 'Insert edit', + OVERWRITE_EDIT: 'Overwrite edit', +} -const HOTKEY_COMMAND_LOOKUP = createHotkeyCommandLookup(); +const HOTKEY_COMMAND_LOOKUP = createHotkeyCommandLookup() function getNavigatorPlatform(): string { - if (typeof navigator === "undefined") return "Windows"; + if (typeof navigator === 'undefined') return 'Windows' const userAgentData = ( navigator as Navigator & { - userAgentData?: { platform?: string }; + userAgentData?: { platform?: string } } - ).userAgentData; + ).userAgentData - if (typeof userAgentData?.platform === "string") { - return userAgentData.platform; + if (typeof userAgentData?.platform === 'string') { + return userAgentData.platform } - return navigator.platform || navigator.userAgent || "Windows"; + return navigator.platform || navigator.userAgent || 'Windows' } function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { - const platform = (platformValue ?? getNavigatorPlatform()).toLowerCase(); - return platform.includes("mac") || - platform.includes("iphone") || - platform.includes("ipad") - ? "mac" - : "windows"; + const platform = (platformValue ?? getNavigatorPlatform()).toLowerCase() + return platform.includes('mac') || platform.includes('iphone') || platform.includes('ipad') + ? 'mac' + : 'windows' } -export function resolveHotkeys( - overrides: HotkeyOverrideMap = {}, -): HotkeyBindingMap { +export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { return { ...HOTKEYS, ...sanitizeHotkeyOverrides(overrides), - }; + } } function isExplicitlyUnassignedHotkey(rawBinding: string): boolean { - return rawBinding.trim() === ""; + return rawBinding.trim() === '' } function isHotkeyKey(value: string): value is HotkeyKey { - return value in HOTKEYS; + return value in HOTKEYS } function resolveHotkeyKey(value: string): HotkeyKey | null { if (isHotkeyKey(value)) { - return value; + return value } - return HOTKEY_COMMAND_ALIASES[value] ?? null; + return HOTKEY_COMMAND_ALIASES[value] ?? null } function normalizeHotkeyCommandLabel(label: string): string { - return label.trim().toLowerCase(); + return label.trim().toLowerCase() } function createHotkeyCommandLookup(): HotkeyCommandLookup { - const byLabel = new Map(); - const byDefaultBinding = new Map(); + const byLabel = new Map() + const byDefaultBinding = new Map() for (const key of Object.keys(HOTKEYS) as HotkeyKey[]) { - byLabel.set(normalizeHotkeyCommandLabel(HOTKEY_DESCRIPTIONS[key]), key); - byDefaultBinding.set(normalizeHotkeyBinding(HOTKEYS[key]), key); + byLabel.set(normalizeHotkeyCommandLabel(HOTKEY_DESCRIPTIONS[key]), key) + byDefaultBinding.set(normalizeHotkeyBinding(HOTKEYS[key]), key) } return { byLabel, byDefaultBinding, - }; + } } function resolveHotkeyImportCommand(command: HotkeyImportCommand): { - key: HotkeyKey | null; - wasRemapped: boolean; + key: HotkeyKey | null + wasRemapped: boolean } { const rawKey = - typeof command.id === "string" + typeof command.id === 'string' ? command.id - : typeof command.key === "string" + : typeof command.key === 'string' ? command.key - : null; + : null if (rawKey) { - const directKey = resolveHotkeyKey(rawKey); + const directKey = resolveHotkeyKey(rawKey) if (directKey) { return { key: directKey, wasRemapped: directKey !== rawKey, - }; + } } } - if (typeof command.label === "string") { - const labelMatch = HOTKEY_COMMAND_LOOKUP.byLabel.get( - normalizeHotkeyCommandLabel(command.label), - ); + if (typeof command.label === 'string') { + const labelMatch = HOTKEY_COMMAND_LOOKUP.byLabel.get(normalizeHotkeyCommandLabel(command.label)) if (labelMatch) { return { key: labelMatch, wasRemapped: true, - }; + } } } - if (typeof command.defaultBinding === "string") { - const normalizedDefaultBinding = normalizeHotkeyBinding( - command.defaultBinding, - ); - const bindingMatch = HOTKEY_COMMAND_LOOKUP.byDefaultBinding.get( - normalizedDefaultBinding, - ); + if (typeof command.defaultBinding === 'string') { + const normalizedDefaultBinding = normalizeHotkeyBinding(command.defaultBinding) + const bindingMatch = HOTKEY_COMMAND_LOOKUP.byDefaultBinding.get(normalizedDefaultBinding) if (bindingMatch) { return { key: bindingMatch, wasRemapped: true, - }; + } } } return { key: null, wasRemapped: false, - }; + } } function normalizeHotkeyToken(token: string): string { - const normalized = token.trim().toLowerCase(); - if (!normalized) return ""; - return HOTKEY_TOKEN_ALIASES[normalized] ?? normalized; + const normalized = token.trim().toLowerCase() + if (!normalized) return '' + return HOTKEY_TOKEN_ALIASES[normalized] ?? normalized } export function splitHotkeyBinding(binding: string): string[] { return binding - .split("+") + .split('+') .map((token) => normalizeHotkeyToken(token)) - .filter(Boolean); + .filter(Boolean) } export function normalizeHotkeyBinding(binding: string): string { - const modifiers = new Set(); - const keys: string[] = []; + const modifiers = new Set() + const keys: string[] = [] for (const token of splitHotkeyBinding(binding)) { if (HOTKEY_MODIFIER_SET.has(token)) { - modifiers.add(token); - continue; + modifiers.add(token) + continue } if (!keys.includes(token)) { - keys.push(token); + keys.push(token) } } const orderedModifiers = Array.from(modifiers).sort((left, right) => { - return ( - (HOTKEY_MODIFIER_ORDER.get(left) ?? 99) - - (HOTKEY_MODIFIER_ORDER.get(right) ?? 99) - ); - }); + return (HOTKEY_MODIFIER_ORDER.get(left) ?? 99) - (HOTKEY_MODIFIER_ORDER.get(right) ?? 99) + }) - return [...orderedModifiers, ...keys].join("+"); + return [...orderedModifiers, ...keys].join('+') } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { - if (!overrides || typeof overrides !== "object") { - return {}; + if (!overrides || typeof overrides !== 'object') { + return {} } - const normalizedOverrides: HotkeyOverrideMap = {}; + const normalizedOverrides: HotkeyOverrideMap = {} for (const [rawKey, rawBinding] of Object.entries(overrides)) { - if (!isHotkeyKey(rawKey) || typeof rawBinding !== "string") { - continue; + const key = resolveHotkeyKey(rawKey) + if (!key || typeof rawBinding !== 'string') { + continue } if (isExplicitlyUnassignedHotkey(rawBinding)) { - normalizedOverrides[rawKey] = ""; - continue; + normalizedOverrides[key] = '' + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue; + continue } - if (normalizedBinding === HOTKEYS[rawKey]) { - continue; + if (normalizedBinding === HOTKEYS[key]) { + continue } - normalizedOverrides[rawKey] = normalizedBinding; + normalizedOverrides[key] = normalizedBinding } - return normalizedOverrides; + return normalizedOverrides } export function hasHotkeyPrimaryToken(binding: string): boolean { - return splitHotkeyBinding(binding).some( - (token) => !HOTKEY_MODIFIER_SET.has(token), - ); + return splitHotkeyBinding(binding).some((token) => !HOTKEY_MODIFIER_SET.has(token)) } function formatHotkeyToken(token: string, platform: HotkeyPlatform): string { - if (token === "mod") { - return platform === "mac" ? "Cmd" : "Ctrl"; + if (token === 'mod') { + return platform === 'mac' ? 'Cmd' : 'Ctrl' } - if (token === "alt") { - return platform === "mac" ? "Option" : "Alt"; + if (token === 'alt') { + return platform === 'mac' ? 'Option' : 'Alt' } - if (token === "shift") { - return "Shift"; + if (token === 'shift') { + return 'Shift' } if (HOTKEY_KEY_LABELS[token]) { - return HOTKEY_KEY_LABELS[token]; + return HOTKEY_KEY_LABELS[token] } if (/^[a-z]$/.test(token)) { - return token.toUpperCase(); + return token.toUpperCase() } - return token; + return token } -export function formatHotkeyBinding( - binding: string, - platformValue?: string, -): string { - const normalizedBinding = normalizeHotkeyBinding(binding); - if (!normalizedBinding) return ""; +export function formatHotkeyBinding(binding: string, platformValue?: string): string { + const normalizedBinding = normalizeHotkeyBinding(binding) + if (!normalizedBinding) return '' - const platform = getHotkeyPlatform(platformValue); + const platform = getHotkeyPlatform(platformValue) return normalizedBinding - .split("+") + .split('+') .map((token) => formatHotkeyToken(token, platform)) - .join(" + "); + .join(' + ') } -export function getBrowserHostileHotkey( - binding: string, -): BrowserHostileHotkey | null { - const normalizedBinding = normalizeHotkeyBinding(binding); +export function getBrowserHostileHotkey(binding: string): BrowserHostileHotkey | null { + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding) { - return null; + return null } - return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null; + return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null } -export function getHotkeyPrimaryTokenFromEventData( - eventData: HotkeyEventData, -): string | null { - const code = eventData.code ?? ""; +export function getHotkeyPrimaryTokenFromEventData(eventData: HotkeyEventData): string | null { + const code = eventData.code ?? '' if (HOTKEY_CODE_TOKEN_MAP[code]) { - return HOTKEY_CODE_TOKEN_MAP[code]; + return HOTKEY_CODE_TOKEN_MAP[code] } - if (code.startsWith("Key") && code.length === 4) { - return code.slice(3).toLowerCase(); + if (code.startsWith('Key') && code.length === 4) { + return code.slice(3).toLowerCase() } - if (code.startsWith("Digit") && code.length === 6) { - return code.slice(5); + if (code.startsWith('Digit') && code.length === 6) { + return code.slice(5) } - if (code.startsWith("Numpad") && code.length === 7) { - return code.slice(6); + if (code.startsWith('Numpad') && code.length === 7) { + return code.slice(6) } - const key = normalizeHotkeyToken(eventData.key ?? ""); + const key = normalizeHotkeyToken(eventData.key ?? '') if (!key || HOTKEY_MODIFIER_SET.has(key)) { - return null; + return null } if (key.length === 1 && /^[a-z0-9]$/.test(key)) { - return key; + return key } - return HOTKEY_KEY_LABELS[key] ? key : null; + return HOTKEY_KEY_LABELS[key] ? key : null } -export function getHotkeyBindingFromEventData( - eventData: HotkeyEventData, -): string | null { - const tokens: string[] = []; +export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): string | null { + const tokens: string[] = [] if (eventData.ctrlKey || eventData.metaKey) { - tokens.push("mod"); + tokens.push('mod') } if (eventData.altKey) { - tokens.push("alt"); + tokens.push('alt') } if (eventData.shiftKey) { - tokens.push("shift"); + tokens.push('shift') } - const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData); + const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData) if (primaryToken) { - tokens.push(primaryToken); + tokens.push(primaryToken) } if (tokens.length === 0) { - return null; + return null } - return normalizeHotkeyBinding(tokens.join("+")); + return normalizeHotkeyBinding(tokens.join('+')) } -function getHotkeyConflictMap( - bindings: HotkeyBindingMap, -): Record { - const conflicts: Record = {}; - - for (const [key, binding] of Object.entries(bindings) as [ - HotkeyKey, - string, - ][]) { - const normalizedBinding = normalizeHotkeyBinding(binding); +function getHotkeyConflictMap(bindings: HotkeyBindingMap): Record { + const conflicts: Record = {} + + for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue; + continue } - conflicts[normalizedBinding] ??= []; - conflicts[normalizedBinding].push(key); + conflicts[normalizedBinding] ??= [] + conflicts[normalizedBinding].push(key) } - return conflicts; + return conflicts } export function findHotkeyConflicts( @@ -709,22 +689,22 @@ export function findHotkeyConflicts( binding: string, currentKey?: HotkeyKey, ): HotkeyKey[] { - const normalizedBinding = normalizeHotkeyBinding(binding); + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - return []; + return [] } return (getHotkeyConflictMap(bindings)[normalizedBinding] ?? []).filter( (key) => key !== currentKey, - ); + ) } export function createHotkeyExportDocument( overrides: HotkeyOverrideMap = {}, ): HotkeyExportDocument { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides); - const bindings = resolveHotkeys(normalizedOverrides); - const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[]; + const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const bindings = resolveHotkeys(normalizedOverrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] return { schema: HOTKEY_EXPORT_SCHEMA, @@ -738,23 +718,23 @@ export function createHotkeyExportDocument( isCustom: key in normalizedOverrides, })), overrides: normalizedOverrides, - }; + } } function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object"; + return Boolean(value) && typeof value === 'object' } function getImportBinding(command: HotkeyImportCommand): string | null { - if (typeof command.binding === "string") { - return command.binding; + if (typeof command.binding === 'string') { + return command.binding } - if (typeof command.shortcut === "string") { - return command.shortcut; + if (typeof command.shortcut === 'string') { + return command.shortcut } - return null; + return null } function collectImportedOverrides(source: unknown): HotkeyImportResult { @@ -765,43 +745,43 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: null, - }; + } } - const normalizedOverrides: HotkeyOverrideMap = {}; - let importedCommandCount = 0; - let ignoredCommandCount = 0; - let remappedCommandCount = 0; + const normalizedOverrides: HotkeyOverrideMap = {} + let importedCommandCount = 0 + let ignoredCommandCount = 0 + let remappedCommandCount = 0 for (const [rawKey, rawBinding] of Object.entries(source)) { - const resolvedKey = resolveHotkeyKey(rawKey); - if (!resolvedKey || typeof rawBinding !== "string") { - ignoredCommandCount += 1; - continue; + const resolvedKey = resolveHotkeyKey(rawKey) + if (!resolvedKey || typeof rawBinding !== 'string') { + ignoredCommandCount += 1 + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (isExplicitlyUnassignedHotkey(rawBinding)) { - normalizedOverrides[resolvedKey] = ""; - importedCommandCount += 1; + normalizedOverrides[resolvedKey] = '' + importedCommandCount += 1 if (resolvedKey !== rawKey) { - remappedCommandCount += 1; + remappedCommandCount += 1 } - continue; + continue } if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedKey !== rawKey) { - remappedCommandCount += 1; + remappedCommandCount += 1 } if (normalizedBinding !== HOTKEYS[resolvedKey]) { - normalizedOverrides[resolvedKey] = normalizedBinding; + normalizedOverrides[resolvedKey] = normalizedBinding } } @@ -811,84 +791,96 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { ignoredCommandCount, remappedCommandCount, sourceVersion: null, - }; + } +} + +function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResult { + if ( + (result.sourceVersion === null || result.sourceVersion < 2) && + result.overrides.EDIT_KEYFRAME_ADD === 'k' + ) { + const overrides = { ...result.overrides } + delete overrides.EDIT_KEYFRAME_ADD + return { ...result, overrides } + } + + return result } export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { if (!isRecord(source)) { - throw new Error("Invalid hotkey preset format"); + throw new Error('Invalid hotkey preset format') } if (source.schema !== HOTKEY_EXPORT_SCHEMA) { - return collectImportedOverrides(source); + return migrateLegacyHotkeyImport(collectImportedOverrides(source)) } - const sourceVersion = - typeof source.version === "number" ? source.version : null; + const sourceVersion = typeof source.version === 'number' ? source.version : null - const overridesSource = isRecord(source.overrides) ? source.overrides : null; - const commandsSource = Array.isArray(source.commands) ? source.commands : []; + const overridesSource = isRecord(source.overrides) ? source.overrides : null + const commandsSource = Array.isArray(source.commands) ? source.commands : [] - let importedCommandCount = 0; - let ignoredCommandCount = 0; - let remappedCommandCount = 0; - const importedOverrides: HotkeyOverrideMap = {}; + let importedCommandCount = 0 + let ignoredCommandCount = 0 + let remappedCommandCount = 0 + const importedOverrides: HotkeyOverrideMap = {} if (overridesSource) { - const overrideImport = collectImportedOverrides(overridesSource); - importedCommandCount += overrideImport.importedCommandCount; - ignoredCommandCount += overrideImport.ignoredCommandCount; - remappedCommandCount += overrideImport.remappedCommandCount; - Object.assign(importedOverrides, overrideImport.overrides); + const overrideImport = collectImportedOverrides(overridesSource) + importedCommandCount += overrideImport.importedCommandCount + ignoredCommandCount += overrideImport.ignoredCommandCount + remappedCommandCount += overrideImport.remappedCommandCount + Object.assign(importedOverrides, overrideImport.overrides) } else { for (const command of commandsSource) { if (!isRecord(command)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - const importCommand = command as HotkeyImportCommand; - const rawBinding = getImportBinding(importCommand); - const resolvedCommand = resolveHotkeyImportCommand(importCommand); + const importCommand = command as HotkeyImportCommand + const rawBinding = getImportBinding(importCommand) + const resolvedCommand = resolveHotkeyImportCommand(importCommand) if (!resolvedCommand.key || rawBinding === null) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - const normalizedBinding = normalizeHotkeyBinding(rawBinding); + const normalizedBinding = normalizeHotkeyBinding(rawBinding) if (isExplicitlyUnassignedHotkey(rawBinding)) { - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedCommand.wasRemapped) { - remappedCommandCount += 1; + remappedCommandCount += 1 } - importedOverrides[resolvedCommand.key] = ""; - continue; + importedOverrides[resolvedCommand.key] = '' + continue } if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - ignoredCommandCount += 1; - continue; + ignoredCommandCount += 1 + continue } - importedCommandCount += 1; + importedCommandCount += 1 if (resolvedCommand.wasRemapped) { - remappedCommandCount += 1; + remappedCommandCount += 1 } if (normalizedBinding !== HOTKEYS[resolvedCommand.key]) { - importedOverrides[resolvedCommand.key] = normalizedBinding; + importedOverrides[resolvedCommand.key] = normalizedBinding } } } - return { + return migrateLegacyHotkeyImport({ overrides: sanitizeHotkeyOverrides(importedOverrides), importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion, - }; + }) } /** @@ -898,4 +890,4 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { export const HOTKEY_OPTIONS = { enableOnFormTags: false, preventDefault: true, -} as const; +} as const diff --git a/src/features/docs/pages/06-timeline.ts b/src/features/docs/pages/06-timeline.ts index 6de463af8..0c9172f48 100644 --- a/src/features/docs/pages/06-timeline.ts +++ b/src/features/docs/pages/06-timeline.ts @@ -48,9 +48,9 @@ const page = { { kind: 'list', items: [ - 'Split at the playhead with `Alt+C`, or use the **Razor** tool (`C`) to cut wherever you click.', + 'Split at the playhead with `Shift+C` (`Alt+C` also works), or use the **Razor** tool (`C`) to cut wherever you click.', 'Join adjacent sections of the same clip with `Shift+J`.', - '**Delete** leaves a gap; **Ripple Delete** (`Ctrl+Delete`) removes the clip and closes the gap.', + '**Delete** leaves a gap; **Ripple Delete** (`Ctrl+Delete` on Windows/Linux, `Cmd+Delete` on macOS) removes the clip and closes the gap.', 'Use **Close All Gaps** to pull clips together and remove empty space on a track.', ], }, diff --git a/src/features/docs/pages/07-editing-tools.ts b/src/features/docs/pages/07-editing-tools.ts index b72b7e745..00cdd70eb 100644 --- a/src/features/docs/pages/07-editing-tools.ts +++ b/src/features/docs/pages/07-editing-tools.ts @@ -32,8 +32,8 @@ const page = { { kind: 'list', items: [ - 'A **ripple** trim changes an edit and shifts all later material, so the total duration changes.', - 'A **rolling** trim moves the cut between two neighboring clips, with no change to overall duration.', + 'Hold `Shift` while dragging a trim edge for a **ripple** trim, which shifts all later material.', + 'Hold `Alt` while dragging a shared edge for a **rolling** trim, which moves the cut between neighboring clips without changing total duration.', 'A **slip** edit changes which source frames appear inside a clip without moving the clip or its neighbors.', 'A **slide** edit moves a clip along the track while automatically adjusting the neighboring cuts.', ], @@ -41,7 +41,7 @@ const page = { { kind: 'note', tone: 'info', - text: 'Ripple and rolling are behaviors of the **Trim edit** tool, not separate tools with their own shortcut.', + text: 'Ripple and rolling are modifier behaviors of the **Trim edit** tool (`T`), not separate tools.', }, ], }, diff --git a/src/features/docs/pages/08-preview.ts b/src/features/docs/pages/08-preview.ts index 2cb57da95..01098379b 100644 --- a/src/features/docs/pages/08-preview.ts +++ b/src/features/docs/pages/08-preview.ts @@ -16,11 +16,17 @@ const page = { kind: 'list', items: [ 'Play and pause with the preview controls or `Space`.', + 'Use `J`, `K`, and `L` for reverse shuttle, pause, and forward shuttle. Repeated `J` or `L` presses increase shuttle speed.', 'Step one frame at a time with `Left` and `Right` for frame-accurate checks.', 'Jump to the start of the timeline with `Home` and the end with `End`.', 'Read the timecode display to confirm the exact playhead position.', ], }, + { + kind: 'note', + tone: 'info', + text: 'When the pointer is over the Source Monitor, `J`, `K`, and `L` control the source. Otherwise they control the program timeline.', + }, ], }, { diff --git a/src/features/docs/pages/09-source-monitor.ts b/src/features/docs/pages/09-source-monitor.ts index 1b246d106..cbc86a65f 100644 --- a/src/features/docs/pages/09-source-monitor.ts +++ b/src/features/docs/pages/09-source-monitor.ts @@ -18,6 +18,7 @@ const page = { 'Double-click a media card, or use **Open In Source Monitor** from Media info, to load a source.', 'The monitor header shows the source file name, with a close control to leave it.', 'Source playback is independent of the timeline preview, so you can scrub a source without moving the timeline playhead.', + 'Hover the Source Monitor and use `J`, `K`, or `L` to shuttle backward, pause, or shuttle forward without affecting program playback.', 'Click the timecode readout to toggle between timecode and frame-number display.', ], }, diff --git a/src/features/docs/pages/20-keyboard-shortcuts.ts b/src/features/docs/pages/20-keyboard-shortcuts.ts index 3977f214c..368a8468c 100644 --- a/src/features/docs/pages/20-keyboard-shortcuts.ts +++ b/src/features/docs/pages/20-keyboard-shortcuts.ts @@ -15,6 +15,7 @@ const page = { headers: ['Action', 'Shortcut'], rows: [ ['Play / Pause', '`Space`'], + ['Shuttle reverse / Pause / Forward', '`J` / `K` / `L`'], ['Previous / Next frame', '`Left` / `Right`'], ['Previous / Next snap point', '`Up` / `Down`'], ['Go to start / end', '`Home` / `End`'], @@ -29,8 +30,7 @@ const page = { kind: 'table', headers: ['Action', 'Shortcut'], rows: [ - ['Split at playhead', '`Alt+C`'], - ['Split at cursor', '`Shift+C`'], + ['Split at playhead', '`Shift+C` / `Alt+C`'], ['Join', '`Shift+J`'], ['Delete / Ripple delete', '`Delete` / `Ctrl+Delete`'], ['Insert freeze frame', '`Shift+F`'], @@ -59,7 +59,7 @@ const page = { { kind: 'note', tone: 'info', - text: 'Ripple and rolling are trim behaviors of the **Trim edit** tool, not separate tools with their own shortcut.', + text: 'With the **Trim edit** tool, hold `Shift` while dragging for a ripple trim or `Alt` for a rolling trim.', }, ], }, @@ -90,7 +90,7 @@ const page = { ['Add / Remove marker', '`M` / `Shift+M`'], ['Previous / Next marker', '`[` / `]`'], ['Clear keyframes', '`Shift+A`'], - ['Add keyframe to selected Edit layer', '`K`'], + ['Add keyframe to selected Edit layer', '`Shift+K`'], ['Keyframe graph / sheet / split view', '`1` / `2` / `3`'], ['Previous / Next property keyframe', '`Alt+[` / `Alt+]`'], ['Toggle auto-key for active property', '`A`'], diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 53d7fb2f7..de38cf132 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,6 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -285,6 +286,32 @@ export interface EditorHostNavigation { back(): void } +export const HOST_SHORTCUTS_SCHEMA = 'freecut-host-shortcuts' +export const HOST_SHORTCUTS_VERSION = 1 + +/** Versioned shortcut payload shared by the host, UI, and agent settings surface. */ +export interface HostShortcutSettings { + schema: typeof HOST_SHORTCUTS_SCHEMA + version: typeof HOST_SHORTCUTS_VERSION + overrides: HotkeyOverrideMap +} + +export interface EditorShortcutPort { + getSettings(): Promise | HostShortcutSettings + setSettings(settings: HostShortcutSettings): Promise | void + subscribe?(listener: (settings: HostShortcutSettings) => void): () => void +} + +export function createHostShortcutSettings( + overrides: HotkeyOverrideMap = {}, +): HostShortcutSettings { + return { + schema: HOST_SHORTCUTS_SCHEMA, + version: HOST_SHORTCUTS_VERSION, + overrides: sanitizeHotkeyOverrides(overrides), + } +} + export interface EditorHost { readonly capabilities: EditorCapabilityMap load(): Promise | EmbeddedEditorSnapshot @@ -292,6 +319,8 @@ export interface EditorHost { locator: MediaLocator, ): Promise | ResolvedMediaLocator | null submitEdit(batch: EditCommandBatch): Promise | HostEditResult + /** Optional host/agent round-trip for user-configurable keyboard shortcuts. */ + shortcuts?: EditorShortcutPort /** Optional application-issued transcript read/preview boundary. */ transcript?: EditorTranscriptPort navigation?: EditorHostNavigation diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index cf3b69fb8..7de9e01c8 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -15,6 +15,7 @@ import { EditorHostProvider } from './context-provider' import { HostCaptionEditorProvider } from './caption-editor-context' import { HostTranscriptEditorProvider } from './transcript-editor-context' import { EmbeddedEditorHostRuntime } from './runtime' +import { mountHostShortcutSettings } from './shortcut-settings' import '@/index.css' interface HostSurfaceState { @@ -33,18 +34,36 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { useEffect(() => { let cancelled = false + let unmountShortcutSettings: (() => void) | undefined setState(null) setError(null) - void Promise.all([Promise.resolve(host.load()), i18nReady]) - .then(([snapshot]) => { - if (cancelled) return + + const initialize = async () => { + unmountShortcutSettings = await mountHostShortcutSettings(host) + if (cancelled) { + unmountShortcutSettings() + unmountShortcutSettings = undefined + return + } + + const [snapshot] = await Promise.all([Promise.resolve(host.load()), i18nReady]) + if (!cancelled) { setState({ snapshot, runtime: new EmbeddedEditorHostRuntime(host, snapshot) }) - }) + } + } + + void initialize() + .then(() => undefined) .catch((caught) => { - if (!cancelled) setError(caught instanceof Error ? caught : new Error(String(caught))) + unmountShortcutSettings?.() + unmountShortcutSettings = undefined + if (cancelled) return + setError(caught instanceof Error ? caught : new Error(String(caught))) }) + return () => { cancelled = true + unmountShortcutSettings?.() } }, [host]) diff --git a/src/features/editor/host/index.ts b/src/features/editor/host/index.ts index 10447f0de..d111ecf95 100644 --- a/src/features/editor/host/index.ts +++ b/src/features/editor/host/index.ts @@ -7,6 +7,8 @@ export type { EditorHostContextValue } from './context' export type { EditorHostProviderProps } from './context-provider' export { DEFAULT_HOST_CAPABILITIES, + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, MAX_TRANSCRIPT_CURSOR_LENGTH, MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, MAX_TRANSCRIPT_DURATION_US, @@ -16,6 +18,7 @@ export { MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, + createHostShortcutSettings, createLocalEditorHost, isHostCapabilityEnabled, } from './contract' @@ -24,6 +27,7 @@ export type { EditorCapabilityMap, EditorHost, EditorHostNavigation, + EditorShortcutPort, EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, @@ -33,6 +37,7 @@ export type { HostEditResult, HostMediaKind, HostNotice, + HostShortcutSettings, HostTranscriptCommandAction, HostTranscriptCommandPreview, HostTranscriptCommandPreviewRequest, diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts new file mode 100644 index 000000000..eb5f3652b --- /dev/null +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment jsdom + +import { createElement } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useSettingsStore } from '@/features/editor/deps/settings' +import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' +import { usePlaybackStore } from '@/shared/state/playback' +import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract' +import { mountHostShortcutSettings } from './shortcut-settings' + +function HostShortcutHarness() { + useHostTimelineShortcuts() + return null +} + +function createShortcutHost(initial: HostShortcutSettings) { + const listeners = new Set<(settings: HostShortcutSettings) => void>() + const setSettings = vi.fn() + const notify = vi.fn() + const host: EditorHost = { + capabilities: {}, + load: vi.fn(() => { + throw new Error('not used') + }), + resolveMedia: vi.fn(() => null), + submitEdit: vi.fn(() => { + throw new Error('not used') + }), + shortcuts: { + getSettings: vi.fn(() => initial), + setSettings, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + }, + notify, + } + + return { + host, + setSettings, + notify, + emit: (settings: HostShortcutSettings) => { + for (const listener of listeners) listener(settings) + }, + } +} + +describe('host shortcut settings round trip', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + }) + + it('hydrates host bindings, persists UI changes, and accepts agent updates', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }) + + render(createElement(HostShortcutHarness)) + fireEvent.keyDown(document, { key: 'e', code: 'KeyE' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + fireEvent.keyDown(document, { key: 'w', code: 'KeyW' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + fireEvent.keyDown(document, { key: 'q', code: 'KeyQ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + + await waitFor(() => + expect(harness.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'x', + SHUTTLE_FORWARD: 'e', + }), + ), + ) + + harness.emit( + createHostShortcutSettings({ + SHUTTLE_REVERSE: 'a', + SHUTTLE_PAUSE: 's', + SHUTTLE_FORWARD: 'd', + }), + ) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + SHUTTLE_REVERSE: 'a', + SHUTTLE_PAUSE: 's', + SHUTTLE_FORWARD: 'd', + }) + expect(harness.notify).not.toHaveBeenCalled() + + unmount() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + }) +}) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts new file mode 100644 index 000000000..df8cc3ff5 --- /dev/null +++ b/src/features/editor/host/shortcut-settings.ts @@ -0,0 +1,86 @@ +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { useSettingsStore } from '@/features/editor/deps/settings' +import { + HOST_SHORTCUTS_SCHEMA, + HOST_SHORTCUTS_VERSION, + createHostShortcutSettings, + type EditorHost, + type HostShortcutSettings, +} from './contract' + +function normalizeHostShortcutSettings(settings: HostShortcutSettings): HostShortcutSettings { + if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) { + throw new Error('Unsupported host shortcut settings schema') + } + + return createHostShortcutSettings(sanitizeHotkeyOverrides(settings.overrides)) +} + +function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { + return { ...overrides } +} + +/** + * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and + * host/agent changes synchronized for the lifetime of the embedded surface. + */ +export async function mountHostShortcutSettings(host: EditorHost): Promise<() => void> { + const port = host.shortcuts + if (!port) { + return () => undefined + } + + const standaloneOverrides = copyOverrides(useSettingsStore.getState().hotkeyOverrides) + let applyingHostSettings = false + let disposed = false + let writeQueue = Promise.resolve() + + const reportFailure = (message: string) => { + host.notify?.({ kind: 'error', message }) + } + + const applyHostSettings = (settings: HostShortcutSettings) => { + if (disposed) return + const normalized = normalizeHostShortcutSettings(settings) + applyingHostSettings = true + try { + useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) + } finally { + applyingHostSettings = false + } + } + + applyHostSettings(await Promise.resolve(port.getSettings())) + + const unsubscribeHost = port.subscribe?.((settings) => { + try { + applyHostSettings(settings) + } catch { + reportFailure('Could not apply keyboard shortcuts from the host.') + } + }) + + const unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { + if ( + disposed || + applyingHostSettings || + state.hotkeyOverrides === previousState.hotkeyOverrides + ) { + return + } + + const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) + writeQueue = writeQueue + .then(() => Promise.resolve(port.setSettings(settings))) + .catch(() => { + reportFailure('Could not save keyboard shortcuts to the host.') + }) + }) + + return () => { + disposed = true + unsubscribeStore() + unsubscribeHost?.() + useSettingsStore.getState().replaceHotkeyOverrides(standaloneOverrides) + } +} diff --git a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx index f3b1f2b92..5e8fee251 100644 --- a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx +++ b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx @@ -1,19 +1,19 @@ -import { fireEvent, render } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; -import { DopesheetEditor } from "./index"; +import { fireEvent, render } from '@testing-library/react' +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' +import { DopesheetEditor } from './index' -describe("DopesheetEditor shortcuts", () => { +describe('DopesheetEditor shortcuts', () => { beforeAll(() => { class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); - }); + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + }) - it("adds a keyframe through the active property handler", () => { - const onAddKeyframe = vi.fn(); + it('adds a keyframe through the active property handler', () => { + const onAddKeyframe = vi.fn() render( { onAddKeyframe={onAddKeyframe} shortcutsEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).toHaveBeenCalledWith("x", 24); - }); + expect(onAddKeyframe).toHaveBeenCalledWith('x', 24) + }) - it("does not remove an existing keyframe when adding with the shortcut", () => { - const onAddKeyframe = vi.fn(); - const onRemoveKeyframes = vi.fn(); + it('does not remove an existing keyframe when adding with the shortcut', () => { + const onAddKeyframe = vi.fn() + const onRemoveKeyframes = vi.fn() render( { onRemoveKeyframes={onRemoveKeyframes} shortcutsEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).not.toHaveBeenCalled(); - expect(onRemoveKeyframes).not.toHaveBeenCalled(); - }); + expect(onAddKeyframe).not.toHaveBeenCalled() + expect(onRemoveKeyframes).not.toHaveBeenCalled() + }) - it("does not fire editor shortcuts while they are out of scope", () => { - const onAddKeyframe = vi.fn(); + it('does not fire editor shortcuts while they are out of scope', () => { + const onAddKeyframe = vi.fn() render( { onAddKeyframe={onAddKeyframe} shortcutsEnabled={false} shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) - expect(onAddKeyframe).not.toHaveBeenCalled(); - }); + expect(onAddKeyframe).not.toHaveBeenCalled() + }) - it("keeps only the Edit add shortcut active outside editor focus", () => { - const onAddKeyframe = vi.fn(); - const onNavigateToKeyframe = vi.fn(); + it('keeps only the Edit add shortcut active outside editor focus', () => { + const onAddKeyframe = vi.fn() + const onNavigateToKeyframe = vi.fn() render( { shortcutsEnabled={false} addKeyframeShortcutEnabled shortcuts={{ - addKeyframe: "k", - previousKeyframe: "alt+bracketleft", - nextKeyframe: "alt+bracketright", - toggleAutoKey: "a", - fitKeyframes: "f", + addKeyframe: 'shift+k', + previousKeyframe: 'alt+bracketleft', + nextKeyframe: 'alt+bracketright', + toggleAutoKey: 'a', + fitKeyframes: 'f', }} />, - ); + ) - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) fireEvent.keyDown(document, { - key: "[", - code: "BracketLeft", + key: '[', + code: 'BracketLeft', altKey: true, - }); + }) - expect(onAddKeyframe).toHaveBeenCalledWith("x", 24); - expect(onNavigateToKeyframe).not.toHaveBeenCalled(); - }); -}); + expect(onAddKeyframe).toHaveBeenCalledWith('x', 24) + expect(onNavigateToKeyframe).not.toHaveBeenCalled() + }) + + it('does not add a keyframe on plain K', () => { + const onAddKeyframe = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + + expect(onAddKeyframe).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/settings/components/hotkey-editor-sections.ts b/src/features/settings/components/hotkey-editor-sections.ts index 26479d236..0b108656b 100644 --- a/src/features/settings/components/hotkey-editor-sections.ts +++ b/src/features/settings/components/hotkey-editor-sections.ts @@ -3,45 +3,42 @@ import { normalizeHotkeyBinding, type HotkeyBindingMap, type HotkeyKey, -} from "@/config/hotkeys"; +} from '@/config/hotkeys' export interface HotkeyEditorItem { /** i18n key for the command label */ - labelKey: string; - keys: readonly HotkeyKey[]; + labelKey: string + keys: readonly HotkeyKey[] } export interface HotkeyEditorSection { /** i18n key for the section title */ - titleKey: string; + titleKey: string /** i18n key for the section blurb */ - blurbKey: string; + blurbKey: string /** * i18n key describing where these shortcuts are active, for sections whose * commands only fire while a specific panel owns focus. Omitted for globally * active sections. */ - scopeKey?: string; - items: readonly HotkeyEditorItem[]; + scopeKey?: string + items: readonly HotkeyEditorItem[] } export interface HotkeyEditorSearchResult { - section: HotkeyEditorSection; - item: HotkeyEditorItem; + section: HotkeyEditorSection + item: HotkeyEditorItem } interface HotkeyEditorSearchOptions { - query: string; - sections: readonly HotkeyEditorSection[]; - hotkeys: HotkeyBindingMap; - translate: (key: string) => string; + query: string + sections: readonly HotkeyEditorSection[] + hotkeys: HotkeyBindingMap + translate: (key: string) => string } -export function getHotkeyBindingDisplayLabel( - binding: string, - unassignedLabel: string, -): string { - return binding ? formatHotkeyBinding(binding) : unassignedLabel; +export function getHotkeyBindingDisplayLabel(binding: string, unassignedLabel: string): string { + return binding ? formatHotkeyBinding(binding) : unassignedLabel } export function getHotkeyEditorSearchResults({ @@ -50,315 +47,315 @@ export function getHotkeyEditorSearchResults({ hotkeys, translate, }: HotkeyEditorSearchOptions): HotkeyEditorSearchResult[] { - const normalizedQuery = query.trim().toLowerCase(); + const normalizedQuery = query.trim().toLowerCase() if (!normalizedQuery) { - return []; + return [] } - const normalizedBindingQuery = normalizeHotkeyBinding(normalizedQuery); + const normalizedBindingQuery = normalizeHotkeyBinding(normalizedQuery) return sections.flatMap((section) => { - const sectionLabel = translate(section.titleKey).toLowerCase(); + const sectionLabel = translate(section.titleKey).toLowerCase() return section.items .filter((item) => { - const itemLabel = translate(item.labelKey).toLowerCase(); - const bindings = item.keys.map((key) => hotkeys[key].toLowerCase()); + const itemLabel = translate(item.labelKey).toLowerCase() + const bindings = item.keys.map((key) => hotkeys[key].toLowerCase()) return ( itemLabel.includes(normalizedQuery) || sectionLabel.includes(normalizedQuery) || - item.keys.some((key) => - key.toLowerCase().includes(normalizedQuery), - ) || + item.keys.some((key) => key.toLowerCase().includes(normalizedQuery)) || bindings.some( (binding) => binding.includes(normalizedQuery) || - (normalizedBindingQuery.length > 0 && - binding === normalizedBindingQuery), + (normalizedBindingQuery.length > 0 && binding === normalizedBindingQuery), ) - ); + ) }) - .map((item) => ({ section, item })); - }); + .map((item) => ({ section, item })) + }) } export const HOTKEY_EDITOR_SECTIONS: readonly HotkeyEditorSection[] = [ { - titleKey: "projects.settings.hotkeys.sections.playback.title", - blurbKey: "projects.settings.hotkeys.sections.playback.blurb", + titleKey: 'projects.settings.hotkeys.sections.playback.title', + blurbKey: 'projects.settings.hotkeys.sections.playback.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.playPause", - keys: ["PLAY_PAUSE"], + labelKey: 'projects.settings.hotkeys.items.playPause', + keys: ['PLAY_PAUSE'], }, { - labelKey: "projects.settings.hotkeys.items.previousFrame", - keys: ["PREVIOUS_FRAME"], + labelKey: 'projects.settings.hotkeys.items.shuttleReverse', + keys: ['SHUTTLE_REVERSE'], }, { - labelKey: "projects.settings.hotkeys.items.nextFrame", - keys: ["NEXT_FRAME"], + labelKey: 'projects.settings.hotkeys.items.shuttlePause', + keys: ['SHUTTLE_PAUSE'], }, { - labelKey: "projects.settings.hotkeys.items.goToStart", - keys: ["GO_TO_START"], + labelKey: 'projects.settings.hotkeys.items.shuttleForward', + keys: ['SHUTTLE_FORWARD'], }, { - labelKey: "projects.settings.hotkeys.items.goToEnd", - keys: ["GO_TO_END"], + labelKey: 'projects.settings.hotkeys.items.previousFrame', + keys: ['PREVIOUS_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.previousSnapPoint", - keys: ["PREVIOUS_SNAP_POINT"], + labelKey: 'projects.settings.hotkeys.items.nextFrame', + keys: ['NEXT_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.nextSnapPoint", - keys: ["NEXT_SNAP_POINT"], + labelKey: 'projects.settings.hotkeys.items.goToStart', + keys: ['GO_TO_START'], + }, + { + labelKey: 'projects.settings.hotkeys.items.goToEnd', + keys: ['GO_TO_END'], + }, + { + labelKey: 'projects.settings.hotkeys.items.previousSnapPoint', + keys: ['PREVIOUS_SNAP_POINT'], + }, + { + labelKey: 'projects.settings.hotkeys.items.nextSnapPoint', + keys: ['NEXT_SNAP_POINT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.editing.title", - blurbKey: "projects.settings.hotkeys.sections.editing.blurb", + titleKey: 'projects.settings.hotkeys.sections.editing.title', + blurbKey: 'projects.settings.hotkeys.sections.editing.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.splitAtPlayhead", - keys: ["SPLIT_AT_PLAYHEAD_ALT"], + labelKey: 'projects.settings.hotkeys.items.splitAtPlayhead', + keys: ['SPLIT_AT_PLAYHEAD', 'SPLIT_AT_PLAYHEAD_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.joinSelectedClips", - keys: ["JOIN_ITEMS"], + labelKey: 'projects.settings.hotkeys.items.joinSelectedClips', + keys: ['JOIN_ITEMS'], }, { - labelKey: "projects.settings.hotkeys.items.deleteSelectedItems", - keys: ["DELETE_SELECTED", "DELETE_SELECTED_ALT"], + labelKey: 'projects.settings.hotkeys.items.deleteSelectedItems', + keys: ['DELETE_SELECTED', 'DELETE_SELECTED_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.rippleDeleteSelectedItems", - keys: ["RIPPLE_DELETE", "RIPPLE_DELETE_ALT"], + labelKey: 'projects.settings.hotkeys.items.rippleDeleteSelectedItems', + keys: ['RIPPLE_DELETE', 'RIPPLE_DELETE_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.insertFreezeFrame", - keys: ["FREEZE_FRAME"], + labelKey: 'projects.settings.hotkeys.items.insertFreezeFrame', + keys: ['FREEZE_FRAME'], }, { - labelKey: "projects.settings.hotkeys.items.linkSelectedClips", - keys: ["LINK_AUDIO_VIDEO"], + labelKey: 'projects.settings.hotkeys.items.linkSelectedClips', + keys: ['LINK_AUDIO_VIDEO'], }, { - labelKey: "projects.settings.hotkeys.items.unlinkSelectedClips", - keys: ["UNLINK_AUDIO_VIDEO"], + labelKey: 'projects.settings.hotkeys.items.unlinkSelectedClips', + keys: ['UNLINK_AUDIO_VIDEO'], }, { - labelKey: "projects.settings.hotkeys.items.toggleLinkedSelection", - keys: ["TOGGLE_LINKED_SELECTION"], + labelKey: 'projects.settings.hotkeys.items.toggleLinkedSelection', + keys: ['TOGGLE_LINKED_SELECTION'], }, { - labelKey: "projects.settings.hotkeys.items.nudge1px", - keys: ["NUDGE_LEFT", "NUDGE_RIGHT", "NUDGE_UP", "NUDGE_DOWN"], + labelKey: 'projects.settings.hotkeys.items.nudge1px', + keys: ['NUDGE_LEFT', 'NUDGE_RIGHT', 'NUDGE_UP', 'NUDGE_DOWN'], }, { - labelKey: "projects.settings.hotkeys.items.nudge10px", - keys: [ - "NUDGE_LEFT_LARGE", - "NUDGE_RIGHT_LARGE", - "NUDGE_UP_LARGE", - "NUDGE_DOWN_LARGE", - ], + labelKey: 'projects.settings.hotkeys.items.nudge10px', + keys: ['NUDGE_LEFT_LARGE', 'NUDGE_RIGHT_LARGE', 'NUDGE_UP_LARGE', 'NUDGE_DOWN_LARGE'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.tools.title", - blurbKey: "projects.settings.hotkeys.sections.tools.blurb", + titleKey: 'projects.settings.hotkeys.sections.tools.title', + blurbKey: 'projects.settings.hotkeys.sections.tools.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.selectionTool", - keys: ["SELECTION_TOOL"], - }, - { - labelKey: "projects.settings.hotkeys.items.trimEditTool", - keys: ["TRIM_EDIT_TOOL"], + labelKey: 'projects.settings.hotkeys.items.selectionTool', + keys: ['SELECTION_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.razorTool", - keys: ["RAZOR_TOOL"], + labelKey: 'projects.settings.hotkeys.items.trimEditTool', + keys: ['TRIM_EDIT_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.splitAtCursor", - keys: ["SPLIT_AT_CURSOR"], + labelKey: 'projects.settings.hotkeys.items.razorTool', + keys: ['RAZOR_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.rateStretchTool", - keys: ["RATE_STRETCH_TOOL"], + labelKey: 'projects.settings.hotkeys.items.rateStretchTool', + keys: ['RATE_STRETCH_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.slipTool", - keys: ["SLIP_TOOL"], + labelKey: 'projects.settings.hotkeys.items.slipTool', + keys: ['SLIP_TOOL'], }, { - labelKey: "projects.settings.hotkeys.items.slideTool", - keys: ["SLIDE_TOOL"], + labelKey: 'projects.settings.hotkeys.items.slideTool', + keys: ['SLIDE_TOOL'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.historyAndUi.title", - blurbKey: "projects.settings.hotkeys.sections.historyAndUi.blurb", + titleKey: 'projects.settings.hotkeys.sections.historyAndUi.title', + blurbKey: 'projects.settings.hotkeys.sections.historyAndUi.blurb', items: [ - { labelKey: "projects.settings.hotkeys.items.undo", keys: ["UNDO"] }, - { labelKey: "projects.settings.hotkeys.items.redo", keys: ["REDO"] }, - { labelKey: "projects.settings.hotkeys.items.zoomIn", keys: ["ZOOM_IN"] }, + { labelKey: 'projects.settings.hotkeys.items.undo', keys: ['UNDO'] }, + { labelKey: 'projects.settings.hotkeys.items.redo', keys: ['REDO'] }, + { labelKey: 'projects.settings.hotkeys.items.zoomIn', keys: ['ZOOM_IN'] }, { - labelKey: "projects.settings.hotkeys.items.zoomOut", - keys: ["ZOOM_OUT"], + labelKey: 'projects.settings.hotkeys.items.zoomOut', + keys: ['ZOOM_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.zoomToFit", - keys: ["ZOOM_TO_FIT"], + labelKey: 'projects.settings.hotkeys.items.zoomToFit', + keys: ['ZOOM_TO_FIT'], }, { - labelKey: "projects.settings.hotkeys.items.zoomTo100", - keys: ["ZOOM_TO_100", "ZOOM_TO_100_ALT"], + labelKey: 'projects.settings.hotkeys.items.zoomTo100', + keys: ['ZOOM_TO_100', 'ZOOM_TO_100_ALT'], }, { - labelKey: "projects.settings.hotkeys.items.toggleSnap", - keys: ["TOGGLE_SNAP"], + labelKey: 'projects.settings.hotkeys.items.toggleSnap', + keys: ['TOGGLE_SNAP'], }, { - labelKey: "projects.settings.hotkeys.items.toggleCanvasSnap", - keys: ["TOGGLE_CANVAS_SNAP"], + labelKey: 'projects.settings.hotkeys.items.toggleCanvasSnap', + keys: ['TOGGLE_CANVAS_SNAP'], }, { - labelKey: "projects.settings.hotkeys.items.editWorkspace", - keys: ["WORKSPACE_EDIT"], + labelKey: 'projects.settings.hotkeys.items.editWorkspace', + keys: ['WORKSPACE_EDIT'], }, { - labelKey: "projects.settings.hotkeys.items.colorWorkspace", - keys: ["WORKSPACE_COLOR"], + labelKey: 'projects.settings.hotkeys.items.colorWorkspace', + keys: ['WORKSPACE_COLOR'], }, { - labelKey: "toolbar.workspaces.motion", - keys: ["WORKSPACE_ANIMATE"], + labelKey: 'toolbar.workspaces.motion', + keys: ['WORKSPACE_ANIMATE'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.clipboard.title", - blurbKey: "projects.settings.hotkeys.sections.clipboard.blurb", + titleKey: 'projects.settings.hotkeys.sections.clipboard.title', + blurbKey: 'projects.settings.hotkeys.sections.clipboard.blurb', items: [ - { labelKey: "projects.settings.hotkeys.items.copy", keys: ["COPY"] }, - { labelKey: "projects.settings.hotkeys.items.cut", keys: ["CUT"] }, - { labelKey: "projects.settings.hotkeys.items.paste", keys: ["PASTE"] }, + { labelKey: 'projects.settings.hotkeys.items.copy', keys: ['COPY'] }, + { labelKey: 'projects.settings.hotkeys.items.cut', keys: ['CUT'] }, + { labelKey: 'projects.settings.hotkeys.items.paste', keys: ['PASTE'] }, ], }, { - titleKey: "projects.settings.hotkeys.sections.markers.title", - blurbKey: "projects.settings.hotkeys.sections.markers.blurb", + titleKey: 'projects.settings.hotkeys.sections.markers.title', + blurbKey: 'projects.settings.hotkeys.sections.markers.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.addMarker", - keys: ["ADD_MARKER"], + labelKey: 'projects.settings.hotkeys.items.addMarker', + keys: ['ADD_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.removeMarker", - keys: ["REMOVE_MARKER"], + labelKey: 'projects.settings.hotkeys.items.removeMarker', + keys: ['REMOVE_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.previousMarker", - keys: ["PREVIOUS_MARKER"], + labelKey: 'projects.settings.hotkeys.items.previousMarker', + keys: ['PREVIOUS_MARKER'], }, { - labelKey: "projects.settings.hotkeys.items.nextMarker", - keys: ["NEXT_MARKER"], + labelKey: 'projects.settings.hotkeys.items.nextMarker', + keys: ['NEXT_MARKER'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.keyframes.title", - blurbKey: "projects.settings.hotkeys.sections.keyframes.blurb", - scopeKey: "projects.settings.hotkeys.scopes.keyframes", + titleKey: 'projects.settings.hotkeys.sections.keyframes.title', + blurbKey: 'projects.settings.hotkeys.sections.keyframes.blurb', + scopeKey: 'projects.settings.hotkeys.scopes.keyframes', items: [ { - labelKey: "projects.settings.hotkeys.items.clearKeyframes", - keys: ["CLEAR_KEYFRAMES"], + labelKey: 'projects.settings.hotkeys.items.clearKeyframes', + keys: ['CLEAR_KEYFRAMES'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorGraph", - keys: ["KEYFRAME_EDITOR_GRAPH"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorGraph', + keys: ['KEYFRAME_EDITOR_GRAPH'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorDopesheet", - keys: ["KEYFRAME_EDITOR_DOPESHEET"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorDopesheet', + keys: ['KEYFRAME_EDITOR_DOPESHEET'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeEditorSplit", - keys: ["KEYFRAME_EDITOR_SPLIT"], + labelKey: 'projects.settings.hotkeys.items.keyframeEditorSplit', + keys: ['KEYFRAME_EDITOR_SPLIT'], }, { - labelKey: "projects.settings.hotkeys.items.editKeyframeAdd", - keys: ["EDIT_KEYFRAME_ADD"], + labelKey: 'projects.settings.hotkeys.items.editKeyframeAdd', + keys: ['EDIT_KEYFRAME_ADD'], }, { - labelKey: "projects.settings.hotkeys.items.keyframePrevious", - keys: ["KEYFRAME_PREVIOUS"], + labelKey: 'projects.settings.hotkeys.items.keyframePrevious', + keys: ['KEYFRAME_PREVIOUS'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeNext", - keys: ["KEYFRAME_NEXT"], + labelKey: 'projects.settings.hotkeys.items.keyframeNext', + keys: ['KEYFRAME_NEXT'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeToggleAuto", - keys: ["KEYFRAME_TOGGLE_AUTO"], + labelKey: 'projects.settings.hotkeys.items.keyframeToggleAuto', + keys: ['KEYFRAME_TOGGLE_AUTO'], }, { - labelKey: "projects.settings.hotkeys.items.keyframeFit", - keys: ["KEYFRAME_FIT"], + labelKey: 'projects.settings.hotkeys.items.keyframeFit', + keys: ['KEYFRAME_FIT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.sourceMonitor.title", - blurbKey: "projects.settings.hotkeys.sections.sourceMonitor.blurb", - scopeKey: "projects.settings.hotkeys.scopes.sourceMonitor", + titleKey: 'projects.settings.hotkeys.sections.sourceMonitor.title', + blurbKey: 'projects.settings.hotkeys.sections.sourceMonitor.blurb', + scopeKey: 'projects.settings.hotkeys.scopes.sourceMonitor', items: [ - { labelKey: "projects.settings.hotkeys.items.markIn", keys: ["MARK_IN"] }, + { labelKey: 'projects.settings.hotkeys.items.markIn', keys: ['MARK_IN'] }, { - labelKey: "projects.settings.hotkeys.items.markOut", - keys: ["MARK_OUT"], + labelKey: 'projects.settings.hotkeys.items.markOut', + keys: ['MARK_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.clearInOut", - keys: ["CLEAR_IN_OUT"], + labelKey: 'projects.settings.hotkeys.items.clearInOut', + keys: ['CLEAR_IN_OUT'], }, { - labelKey: "projects.settings.hotkeys.items.insertEdit", - keys: ["INSERT_EDIT"], + labelKey: 'projects.settings.hotkeys.items.insertEdit', + keys: ['INSERT_EDIT'], }, { - labelKey: "projects.settings.hotkeys.items.overwriteEdit", - keys: ["OVERWRITE_EDIT"], + labelKey: 'projects.settings.hotkeys.items.overwriteEdit', + keys: ['OVERWRITE_EDIT'], }, ], }, { - titleKey: "projects.settings.hotkeys.sections.project.title", - blurbKey: "projects.settings.hotkeys.sections.project.blurb", + titleKey: 'projects.settings.hotkeys.sections.project.title', + blurbKey: 'projects.settings.hotkeys.sections.project.blurb', items: [ { - labelKey: "projects.settings.hotkeys.items.saveProject", - keys: ["SAVE"], + labelKey: 'projects.settings.hotkeys.items.saveProject', + keys: ['SAVE'], }, { - labelKey: "projects.settings.hotkeys.items.exportVideo", - keys: ["EXPORT"], + labelKey: 'projects.settings.hotkeys.items.exportVideo', + keys: ['EXPORT'], }, { - labelKey: "projects.settings.hotkeys.items.openSceneBrowser", - keys: ["OPEN_SCENE_BROWSER"], + labelKey: 'projects.settings.hotkeys.items.openSceneBrowser', + keys: ['OPEN_SCENE_BROWSER'], }, ], }, -] as const; +] as const diff --git a/src/features/timeline/components/timeline-header.test.tsx b/src/features/timeline/components/timeline-header.test.tsx index 9519d275b..8bef3bf32 100644 --- a/src/features/timeline/components/timeline-header.test.tsx +++ b/src/features/timeline/components/timeline-header.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ZOOM_MAX, ZOOM_MIN } from '../constants' import { useZoomStore } from '../stores/zoom-store' import { useSelectionStore } from '@/shared/state/selection' +import { useSettingsStore } from '@/features/timeline/deps/settings' import { TimelineHeader } from './timeline-header' const { micRenderSpy, sliderRenderSpy, sliderInput } = vi.hoisted(() => ({ @@ -91,6 +92,7 @@ describe('TimelineHeader zoom slider', () => { micRenderSpy.mockClear() sliderRenderSpy.mockClear() sliderInput.value = 0.75 + useSettingsStore.getState().resetHotkeys() useZoomStore.getState().setZoomLevelSynchronized(1) useSelectionStore.setState({ selectedItemIds: [], @@ -316,4 +318,33 @@ describe('TimelineHeader zoom slider', () => { 'true', ) }) + + it('shows resolved tool, split, ripple-trim, and rolling-trim shortcuts', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + SELECTION_TOOL: 'q', + TRIM_EDIT_TOOL: 'w', + RAZOR_TOOL: 'e', + SPLIT_AT_PLAYHEAD: 'shift+x', + RATE_STRETCH_TOOL: 'd', + }) + + render() + + expect(screen.getByRole('button', { name: 'Select Tool (Q)' })).toHaveAttribute( + 'data-tooltip', + 'Select Tool (Q)', + ) + expect( + screen.getByRole('button', { + name: 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag', + }), + ).toHaveAttribute('data-tooltip', 'Trim Edit Tool (W) · Ripple: Shift-drag · Roll: Alt-drag') + expect( + screen.getByRole('button', { name: 'Razor Tool (E) · Split: Shift + X' }), + ).toHaveAttribute('data-tooltip', 'Razor Tool (E) · Split: Shift + X') + expect(screen.getByRole('button', { name: 'Rate Stretch Tool (D)' })).toHaveAttribute( + 'data-tooltip', + 'Rate Stretch Tool (D)', + ) + }) }) diff --git a/src/features/timeline/components/timeline-header.tsx b/src/features/timeline/components/timeline-header.tsx index 586efa104..dc85fbedd 100644 --- a/src/features/timeline/components/timeline-header.tsx +++ b/src/features/timeline/components/timeline-header.tsx @@ -59,6 +59,11 @@ function TrimEditIcon({ className }: { className?: string }) { ) } +function labelWithShortcut(label: string, binding: string): string { + const shortcut = formatHotkeyBinding(binding) + return shortcut ? `${label} (${shortcut})` : label +} + const InlineKeyframesToggle = memo(function InlineKeyframesToggle({ isOpen, onToggle, @@ -483,6 +488,29 @@ export const TimelineHeader = memo(function TimelineHeader({ width: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, } as const + const selectToolTooltip = labelWithShortcut( + t('timeline.header.selectToolTooltip'), + hotkeys.SELECTION_TOOL, + ) + const trimEditToolTooltip = [ + labelWithShortcut(t('timeline.header.trimEditToolTooltip'), hotkeys.TRIM_EDIT_TOOL), + t('timeline.header.rippleTrimHint', { modifier: formatHotkeyBinding('shift') }), + t('timeline.header.rollingTrimHint', { modifier: formatHotkeyBinding('alt') }), + ].join(' · ') + const razorToolTooltipParts = [ + labelWithShortcut(t('timeline.header.razorToolTooltip'), hotkeys.RAZOR_TOOL), + ] + const splitAtPlayheadShortcut = formatHotkeyBinding(hotkeys.SPLIT_AT_PLAYHEAD) + if (splitAtPlayheadShortcut) { + razorToolTooltipParts.push( + t('timeline.header.splitAtPlayheadHint', { shortcut: splitAtPlayheadShortcut }), + ) + } + const razorToolTooltip = razorToolTooltipParts.join(' · ') + const rateStretchToolTooltip = labelWithShortcut( + t('timeline.header.rateStretchToolTooltip'), + hotkeys.RATE_STRETCH_TOOL, + ) const handleUndo = () => { useTimelineStore.temporal.getState().undo() @@ -522,8 +550,8 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool('select')} - aria-label={t('timeline.header.selectTool')} - data-tooltip={t('timeline.header.selectToolTooltip')} + aria-label={selectToolTooltip} + data-tooltip={selectToolTooltip} > @@ -538,8 +566,8 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool(activeTool === 'trim-edit' ? 'select' : 'trim-edit')} - aria-label={t('timeline.header.trimEditTool')} - data-tooltip={t('timeline.header.trimEditToolTooltip')} + aria-label={trimEditToolTooltip} + data-tooltip={trimEditToolTooltip} > @@ -554,8 +582,8 @@ export const TimelineHeader = memo(function TimelineHeader({ : '' } onClick={() => setActiveTool(activeTool === 'razor' ? 'select' : 'razor')} - aria-label={t('timeline.header.razorTool')} - data-tooltip={t('timeline.header.razorToolTooltip')} + aria-label={razorToolTooltip} + data-tooltip={razorToolTooltip} > @@ -573,8 +601,8 @@ export const TimelineHeader = memo(function TimelineHeader({ onClick={() => setActiveTool(activeTool === 'rate-stretch' ? 'select' : 'rate-stretch') } - aria-label={t('timeline.header.rateStretchTool')} - data-tooltip={t('timeline.header.rateStretchToolTooltip')} + aria-label={rateStretchToolTooltip} + data-tooltip={rateStretchToolTooltip} > diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 2de34bbd1..c67971c01 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -47,11 +47,13 @@ vi.mock('@/features/timeline/deps/analysis', () => ({ })) vi.mock('@/features/timeline/deps/settings', () => ({ - useResolvedHotkeys: () => ({}), + useResolvedHotkeys: () => ({ + RIPPLE_DELETE: 'mod+backspace', + }), })) vi.mock('@/config/hotkeys', () => ({ - formatHotkeyBinding: () => '', + formatHotkeyBinding: (binding: string) => (binding === 'mod+backspace' ? 'Ctrl + Backspace' : ''), })) function renderContextMenu(overrides: Partial> = {}) { @@ -121,6 +123,12 @@ describe('ItemContextMenu scene detection', () => { expect(screen.getByRole('button', { name: 'AI (Liquid Vision)' })).toBeInTheDocument() }) + it('shows the resolved ripple-delete keycap', () => { + renderContextMenu() + + expect(screen.getByText('Ctrl + Backspace')).toBeInTheDocument() + }) + it('dispatches the selected verification model when a scene detection option is clicked', () => { const { onDetectScenes } = renderContextMenu() diff --git a/src/features/timeline/components/timeline-item/item-context-menu.tsx b/src/features/timeline/components/timeline-item/item-context-menu.tsx index 8c087f571..daca97a06 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.tsx @@ -705,6 +705,7 @@ function CompositionActions({ function DestructiveActions({ t, + hotkeys, isSelected, canRippleDelete = true, canDelete = true, @@ -720,7 +721,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('timeline.contextMenu.rippleDelete')} - Ctrl+Del + {formatHotkeyBinding(hotkeys.RIPPLE_DELETE)} )} {canDelete && ( diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx new file mode 100644 index 000000000..3e0190139 --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useSettingsStore } from '@/features/timeline/deps/settings' +import { usePlaybackStore } from '@/shared/state/playback' +import { useSourcePlayerStore } from '@/shared/state/source-player' +import type { SourcePlayerMethods } from '@/shared/state/source-player/types' +import { usePlaybackShortcuts } from './use-playback-shortcuts' + +function PlaybackShortcutHarness() { + usePlaybackShortcuts({}) + return +} + +function sourcePlayerMethods(): SourcePlayerMethods { + return { + toggle: vi.fn(), + pause: vi.fn(), + isPlaying: vi.fn(() => true), + shuttleForward: vi.fn(), + shuttleReverse: vi.fn(), + seek: vi.fn(), + frameBack: vi.fn(), + frameForward: vi.fn(), + getDurationInFrames: vi.fn(() => 300), + } +} + +describe('usePlaybackShortcuts transport routing', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + currentFrame: 0, + previewFrame: null, + previewItemId: null, + }) + useSourcePlayerStore.setState({ + hoveredPanel: null, + playerMethods: null, + }) + }) + + it('routes J, K, and L to reverse, pause, and forward program transport', () => { + render() + + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + + fireEvent.keyDown(document, { key: 'j', code: 'KeyJ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + }) + + it('claims K as pause even when program transport is already paused', () => { + render() + + expect(fireEvent.keyDown(document, { key: 'k', code: 'KeyK' })).toBe(false) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + }) + + it('routes J, K, and L to the source monitor while it is hovered', () => { + const playerMethods = sourcePlayerMethods() + useSourcePlayerStore.setState({ hoveredPanel: 'source', playerMethods }) + render() + + fireEvent.keyDown(document, { key: 'j', code: 'KeyJ' }) + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) + + expect(playerMethods.shuttleReverse).toHaveBeenCalledTimes(1) + expect(playerMethods.pause).toHaveBeenCalledTimes(1) + expect(playerMethods.shuttleForward).toHaveBeenCalledTimes(1) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + }) + + it('protects editable fields from transport shortcuts', () => { + render() + const input = screen.getByRole('textbox', { name: 'Editable title' }) + + fireEvent.keyDown(input, { key: 'j', code: 'KeyJ' }) + fireEvent.keyDown(input, { key: 'k', code: 'KeyK' }) + fireEvent.keyDown(input, { key: 'l', code: 'KeyL' }) + + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + }) + + it('routes customized transport bindings instead of the defaults', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }) + render() + + fireEvent.keyDown(document, { key: 'l', code: 'KeyL' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + + fireEvent.keyDown(document, { key: 'e', code: 'KeyE' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: 1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'w', code: 'KeyW' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + + fireEvent.keyDown(document, { key: 'q', code: 'KeyQ' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + }) +}) diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index aaa25969f..6892b51ff 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -74,10 +74,10 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [togglePlayPause, isPlaying, callbacks], ) - // Shuttle: L advances forward through 1x, 2x, and 4x. Ignore browser key + // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. useHotkeys( - 'l', + hotkeys.SHUTTLE_FORWARD, (event) => { if (event.repeat) return event.preventDefault() @@ -96,10 +96,10 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [callbacks, shuttleForward], ) - // Shuttle: J mirrors L in reverse. Browser media stays on a paused visual + // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. useHotkeys( - 'j', + hotkeys.SHUTTLE_REVERSE, (event) => { if (event.repeat) return event.preventDefault() @@ -118,25 +118,24 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { [callbacks, shuttleReverse], ) - // K owns pause only while a transport is active. When already paused it - // yields to the existing Edit keyframe shortcut. + // Pause always owns its binding, including while already paused, so transport + // routing cannot fall through to another command. useHotkeys( - 'k', + hotkeys.SHUTTLE_PAUSE, (event) => { if (event.repeat) return + event.preventDefault() + event.stopPropagation() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() if (hoveredPanel === 'source' && playerMethods) { - if (!playerMethods.isPlaying()) return - event.preventDefault() - event.stopPropagation() playerMethods.pause() return } - if (!usePlaybackStore.getState().isPlaying) return - event.preventDefault() - event.stopPropagation() + const wasPlaying = usePlaybackStore.getState().isPlaying pause() - callbacks.onPause?.() + if (wasPlaying) { + callbacks.onPause?.() + } }, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [callbacks, pause], diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 9dc5a4c1c..27170fa2e 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -1,5 +1,5 @@ /** - * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at cursor), R (Rate Stretch). + * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ import { useHotkeys } from 'react-hotkeys-hook' @@ -51,7 +51,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) useHotkeys( - hotkeys.SPLIT_AT_CURSOR, + hotkeys.SPLIT_AT_PLAYHEAD, (event) => { event.preventDefault() const { previewFrame, previewItemId, currentFrame } = usePlaybackStore.getState() diff --git a/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx b/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx index 3e2518986..cc5bee457 100644 --- a/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx +++ b/src/features/timeline/hooks/use-host-timeline-shortcuts.test.tsx @@ -130,6 +130,25 @@ describe('useHostTimelineShortcuts', () => { expect(useTimelineStore.getState().items).toHaveLength(0) }) + it('splits the hovered clip at the playhead on Shift+C', () => { + usePlaybackStore.setState({ + currentFrame: 15, + previewFrame: null, + previewItemId: 'clip-1', + }) + render() + + fireEvent.keyDown(document, { key: 'C', code: 'KeyC', shiftKey: true }) + + expect(useTimelineStore.getState().items).toHaveLength(2) + expect(useTimelineStore.getState().items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'clip-1', from: 0, durationInFrames: 15 }), + expect.objectContaining({ from: 15, durationInFrames: 15 }), + ]), + ) + }) + it('does not undo timeline edits on Mod+Z in host mode', () => { useTimelineStore.getState().moveItem('clip-1', 30) expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) diff --git a/src/i18n/locales/partials/de/projects.json b/src/i18n/locales/partials/de/projects.json index 5e76a71c0..de56958f6 100644 --- a/src/i18n/locales/partials/de/projects.json +++ b/src/i18n/locales/partials/de/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Wiedergabe/Pause", + "shuttleReverse": "Rückwärtswiedergabe", + "shuttlePause": "Transport pausieren", + "shuttleForward": "Vorwärtswiedergabe", "previousFrame": "Vorheriges Bild", "nextFrame": "Nächstes Bild", "goToStart": "Zum Anfang gehen", diff --git a/src/i18n/locales/partials/de/timeline.json b/src/i18n/locales/partials/de/timeline.json index 2aa1cb918..15bfdb50d 100644 --- a/src/i18n/locales/partials/de/timeline.json +++ b/src/i18n/locales/partials/de/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Verknüpfte Auswahl: {{state}} ({{shortcut}})", "rateStretchTool": "Rate Strecken Werkzeug", "rateStretchToolTooltip": "Rate Strecken Werkzeug", + "rippleTrimHint": "Ripple: {{modifier}}-Ziehen", + "rollingTrimHint": "Rollen: {{modifier}}-Ziehen", + "splitAtPlayheadHint": "Teilen: {{shortcut}}", "razorTool": "Rasiermesser Werkzeug", "razorToolTooltip": "Rasiermesser Werkzeug", "redo": "Wiederholen", diff --git a/src/i18n/locales/partials/en/projects.json b/src/i18n/locales/partials/en/projects.json index 900737ba0..1eaa80d17 100644 --- a/src/i18n/locales/partials/en/projects.json +++ b/src/i18n/locales/partials/en/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Play/Pause", + "shuttleReverse": "Shuttle reverse", + "shuttlePause": "Pause transport", + "shuttleForward": "Shuttle forward", "previousFrame": "Previous frame", "nextFrame": "Next frame", "goToStart": "Go to start", diff --git a/src/i18n/locales/partials/en/timeline.json b/src/i18n/locales/partials/en/timeline.json index 9c7cd3037..afa1b9cc6 100644 --- a/src/i18n/locales/partials/en/timeline.json +++ b/src/i18n/locales/partials/en/timeline.json @@ -159,6 +159,9 @@ "linkedSelectionTooltip": "Linked selection: {{state}} ({{shortcut}})", "rateStretchTool": "Rate Stretch Tool", "rateStretchToolTooltip": "Rate Stretch Tool", + "rippleTrimHint": "Ripple: {{modifier}}-drag", + "rollingTrimHint": "Roll: {{modifier}}-drag", + "splitAtPlayheadHint": "Split: {{shortcut}}", "razorTool": "Razor Tool", "razorToolTooltip": "Razor Tool", "redo": "Redo", diff --git a/src/i18n/locales/partials/es/projects.json b/src/i18n/locales/partials/es/projects.json index 2fbda2df9..917df419d 100644 --- a/src/i18n/locales/partials/es/projects.json +++ b/src/i18n/locales/partials/es/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Reproducir/Pausar", + "shuttleReverse": "Reproducción inversa", + "shuttlePause": "Pausar transporte", + "shuttleForward": "Reproducción hacia delante", "previousFrame": "Fotograma anterior", "nextFrame": "Fotograma siguiente", "goToStart": "Ir al inicio", diff --git a/src/i18n/locales/partials/es/timeline.json b/src/i18n/locales/partials/es/timeline.json index ffd360249..63266030b 100644 --- a/src/i18n/locales/partials/es/timeline.json +++ b/src/i18n/locales/partials/es/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Selección vinculada: {{state}} ({{shortcut}})", "rateStretchTool": "tasa estirar herramienta", "rateStretchToolTooltip": "tasa estirar herramienta", + "rippleTrimHint": "Ripple: arrastrar con {{modifier}}", + "rollingTrimHint": "Rodar: arrastrar con {{modifier}}", + "splitAtPlayheadHint": "Dividir: {{shortcut}}", "razorTool": "cuchilla herramienta", "razorToolTooltip": "cuchilla herramienta", "redo": "Rehacer", diff --git a/src/i18n/locales/partials/fr/projects.json b/src/i18n/locales/partials/fr/projects.json index c922c2204..4d991eeca 100644 --- a/src/i18n/locales/partials/fr/projects.json +++ b/src/i18n/locales/partials/fr/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Lecture/Pause", + "shuttleReverse": "Lecture arrière", + "shuttlePause": "Mettre le transport en pause", + "shuttleForward": "Lecture avant", "previousFrame": "Image précédente", "nextFrame": "Image suivante", "goToStart": "Aller au début", diff --git a/src/i18n/locales/partials/fr/timeline.json b/src/i18n/locales/partials/fr/timeline.json index 5bb415a38..9a1c40766 100644 --- a/src/i18n/locales/partials/fr/timeline.json +++ b/src/i18n/locales/partials/fr/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Sélection liée : {{state}} ({{shortcut}})", "rateStretchTool": "vitesse etirer outil", "rateStretchToolTooltip": "vitesse etirer outil", + "rippleTrimHint": "Ripple : {{modifier}}-glisser", + "rollingTrimHint": "Roll : {{modifier}}-glisser", + "splitAtPlayheadHint": "Scinder : {{shortcut}}", "razorTool": "rasoir outil", "razorToolTooltip": "rasoir outil", "redo": "Retablir", diff --git a/src/i18n/locales/partials/ja/projects.json b/src/i18n/locales/partials/ja/projects.json index 97c4295d6..6a15f0336 100644 --- a/src/i18n/locales/partials/ja/projects.json +++ b/src/i18n/locales/partials/ja/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "再生/一時停止", + "shuttleReverse": "逆方向シャトル", + "shuttlePause": "トランスポートを一時停止", + "shuttleForward": "順方向シャトル", "previousFrame": "前のフレーム", "nextFrame": "次のフレーム", "goToStart": "先頭に移動", diff --git a/src/i18n/locales/partials/ja/timeline.json b/src/i18n/locales/partials/ja/timeline.json index e5584ee17..3c16ff3ce 100644 --- a/src/i18n/locales/partials/ja/timeline.json +++ b/src/i18n/locales/partials/ja/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "リンク選択: {{state}} ({{shortcut}})", "rateStretchTool": "レート調整ツール", "rateStretchToolTooltip": "レート調整ツール", + "rippleTrimHint": "リップル: {{modifier}}+ドラッグ", + "rollingTrimHint": "ロール: {{modifier}}+ドラッグ", + "splitAtPlayheadHint": "分割: {{shortcut}}", "razorTool": "レーザーツール", "razorToolTooltip": "レーザーツール", "redo": "やり直し", diff --git a/src/i18n/locales/partials/ko/projects.json b/src/i18n/locales/partials/ko/projects.json index d55ae52d5..02c7830d5 100644 --- a/src/i18n/locales/partials/ko/projects.json +++ b/src/i18n/locales/partials/ko/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "재생/일시정지", + "shuttleReverse": "역방향 셔틀", + "shuttlePause": "전송 일시 정지", + "shuttleForward": "정방향 셔틀", "previousFrame": "이전 프레임", "nextFrame": "다음 프레임", "goToStart": "처음으로 이동", diff --git a/src/i18n/locales/partials/ko/timeline.json b/src/i18n/locales/partials/ko/timeline.json index c8658b4f9..604bce00d 100644 --- a/src/i18n/locales/partials/ko/timeline.json +++ b/src/i18n/locales/partials/ko/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "연결된 선택: {{state}} ({{shortcut}})", "rateStretchTool": "속도 늘이기 도구", "rateStretchToolTooltip": "속도 늘이기 도구", + "rippleTrimHint": "리플: {{modifier}}+드래그", + "rollingTrimHint": "롤: {{modifier}}+드래그", + "splitAtPlayheadHint": "분할: {{shortcut}}", "razorTool": "자르기 도구", "razorToolTooltip": "자르기 도구", "redo": "다시 실행", diff --git a/src/i18n/locales/partials/pt-BR/projects.json b/src/i18n/locales/partials/pt-BR/projects.json index ef4148de9..dfc1693d8 100644 --- a/src/i18n/locales/partials/pt-BR/projects.json +++ b/src/i18n/locales/partials/pt-BR/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Reproduzir/Pausar", + "shuttleReverse": "Shuttle reverso", + "shuttlePause": "Pausar transporte", + "shuttleForward": "Shuttle para frente", "previousFrame": "Quadro anterior", "nextFrame": "Próximo quadro", "goToStart": "Ir para o início", diff --git a/src/i18n/locales/partials/pt-BR/timeline.json b/src/i18n/locales/partials/pt-BR/timeline.json index 6df049842..0a4175726 100644 --- a/src/i18n/locales/partials/pt-BR/timeline.json +++ b/src/i18n/locales/partials/pt-BR/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Seleção vinculada: {{state}} ({{shortcut}})", "rateStretchTool": "Ferramenta de esticar taxa", "rateStretchToolTooltip": "Ferramenta de esticar taxa", + "rippleTrimHint": "Ripple: arraste com {{modifier}}", + "rollingTrimHint": "Rolagem: arraste com {{modifier}}", + "splitAtPlayheadHint": "Dividir: {{shortcut}}", "razorTool": "Ferramenta navalha", "razorToolTooltip": "Ferramenta navalha", "redo": "Refazer", diff --git a/src/i18n/locales/partials/tr/projects.json b/src/i18n/locales/partials/tr/projects.json index d5fd71de1..bd9aca174 100644 --- a/src/i18n/locales/partials/tr/projects.json +++ b/src/i18n/locales/partials/tr/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "Oynat/Duraklat", + "shuttleReverse": "Geri sarma", + "shuttlePause": "Oynatmayı duraklat", + "shuttleForward": "İleri sarma", "previousFrame": "Önceki kare", "nextFrame": "Sonraki kare", "goToStart": "Başa git", diff --git a/src/i18n/locales/partials/tr/timeline.json b/src/i18n/locales/partials/tr/timeline.json index 3f3f47097..c45a3a93e 100644 --- a/src/i18n/locales/partials/tr/timeline.json +++ b/src/i18n/locales/partials/tr/timeline.json @@ -155,6 +155,9 @@ "linkedSelectionTooltip": "Bağlı seçim: {{state}} ({{shortcut}})", "rateStretchTool": "Hız uzatma aracı", "rateStretchToolTooltip": "Hız uzatma aracı", + "rippleTrimHint": "Ripple: {{modifier}} ile sürükle", + "rollingTrimHint": "Roll: {{modifier}} ile sürükle", + "splitAtPlayheadHint": "Böl: {{shortcut}}", "razorTool": "Kesici aracı", "razorToolTooltip": "Kesici aracı", "redo": "Yinele", diff --git a/src/i18n/locales/partials/zh/projects.json b/src/i18n/locales/partials/zh/projects.json index 8cef84c29..9ea3e9e35 100644 --- a/src/i18n/locales/partials/zh/projects.json +++ b/src/i18n/locales/partials/zh/projects.json @@ -447,6 +447,9 @@ }, "items": { "playPause": "播放/暂停", + "shuttleReverse": "反向穿梭", + "shuttlePause": "暂停传输", + "shuttleForward": "正向穿梭", "previousFrame": "上一帧", "nextFrame": "下一帧", "goToStart": "跳到开头", diff --git a/src/i18n/locales/partials/zh/timeline.json b/src/i18n/locales/partials/zh/timeline.json index cbfd24510..5ffec6364 100644 --- a/src/i18n/locales/partials/zh/timeline.json +++ b/src/i18n/locales/partials/zh/timeline.json @@ -159,6 +159,9 @@ "linkedSelectionTooltip": "联动选择:{{state}} ({{shortcut}})", "rateStretchTool": "速率拉伸工具", "rateStretchToolTooltip": "速率拉伸工具", + "rippleTrimHint": "波纹: {{modifier}}+拖动", + "rollingTrimHint": "滚动: {{modifier}}+拖动", + "splitAtPlayheadHint": "分割: {{shortcut}}", "razorTool": "剃刀工具", "razorToolTooltip": "剃刀工具", "redo": "重做", From fe2bdef322bdb6a036e5e722887738e366efbc7f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:19:29 -0700 Subject: [PATCH 02/21] fix(host): guard shortcut settings ownership epochs --- .../editor/host/shortcut-settings.test.ts | 51 +++++++++++++++++++ src/features/editor/host/shortcut-settings.ts | 47 +++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index eb5f3652b..ce3a062c5 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -42,6 +42,7 @@ function createShortcutHost(initial: HostShortcutSettings) { host, setSettings, notify, + listenerCount: () => listeners.size, emit: (settings: HostShortcutSettings) => { for (const listener of listeners) listener(settings) }, @@ -124,4 +125,54 @@ describe('host shortcut settings round trip', () => { PLAY_PAUSE: 'shift+space', }) }) + + it('keeps late hydration from host A inert after host B replaces it', async () => { + let resolveA!: (settings: HostShortcutSettings) => void + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + hostA.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveA = resolve)), + ) + const mountA = mountHostShortcutSettings(hostA.host) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host) + resolveA(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + const unmountA = await mountA + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_REVERSE: 'b' }) + expect(hostA.listenerCount()).toBe(0) + unmountA() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_REVERSE: 'b' }) + unmountB() + }) + + it('does not execute a queued write after its host is disposed', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + const pending = Promise.resolve() + host.setSettings.mockReturnValueOnce(pending) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + unmount() + await Promise.resolve() + expect(host.setSettings).not.toHaveBeenCalled() + }) + + it('drops an older outbound write when newer host input arrives', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'y' })) + await Promise.resolve() + expect(host.setSettings).not.toHaveBeenCalled() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'y' }) + unmount() + }) + + it('removes the host subscriber on unmount', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const unmount = await mountHostShortcutSettings(host.host) + expect(host.listenerCount()).toBe(1) + unmount() + expect(host.listenerCount()).toBe(0) + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index df8cc3ff5..a7685a1f6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -20,6 +20,14 @@ function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { return { ...overrides } } +interface ShortcutOwnership { + epoch: number + standaloneOverrides: HotkeyOverrideMap +} + +let nextOwnershipEpoch = 0 +let currentOwnership: ShortcutOwnership | null = null + /** * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and * host/agent changes synchronized for the lifetime of the embedded surface. @@ -30,18 +38,29 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return () => undefined } - const standaloneOverrides = copyOverrides(useSettingsStore.getState().hotkeyOverrides) + const ownership: ShortcutOwnership = { + epoch: ++nextOwnershipEpoch, + standaloneOverrides: copyOverrides( + currentOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, + ), + } + currentOwnership = ownership let applyingHostSettings = false let disposed = false + let inboundRevision = 0 let writeQueue = Promise.resolve() + const isCurrent = () => + !disposed && currentOwnership?.epoch === ownership.epoch + const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) } const applyHostSettings = (settings: HostShortcutSettings) => { - if (disposed) return + if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) + inboundRevision += 1 applyingHostSettings = true try { useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) @@ -50,9 +69,20 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } } - applyHostSettings(await Promise.resolve(port.getSettings())) + let initialSettings: HostShortcutSettings + try { + initialSettings = await Promise.resolve(port.getSettings()) + } catch (error) { + if (currentOwnership?.epoch === ownership.epoch) currentOwnership = null + throw error + } + if (!isCurrent()) { + return () => undefined + } + applyHostSettings(initialSettings) const unsubscribeHost = port.subscribe?.((settings) => { + if (!isCurrent()) return try { applyHostSettings(settings) } catch { @@ -70,8 +100,12 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) + const revisionAtQueue = inboundRevision writeQueue = writeQueue - .then(() => Promise.resolve(port.setSettings(settings))) + .then(() => { + if (!isCurrent() || inboundRevision !== revisionAtQueue) return undefined + return Promise.resolve(port.setSettings(settings)) + }) .catch(() => { reportFailure('Could not save keyboard shortcuts to the host.') }) @@ -79,8 +113,11 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return () => { disposed = true + inboundRevision += 1 unsubscribeStore() unsubscribeHost?.() - useSettingsStore.getState().replaceHotkeyOverrides(standaloneOverrides) + if (currentOwnership?.epoch !== ownership.epoch) return + currentOwnership = null + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) } } From 7ebce1a12e181a0ea4fbf87433f7c43d519fae9d Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:20:33 -0700 Subject: [PATCH 03/21] fix: make shortcut labels reactive in editor menus --- .../components/source-monitor.test.tsx | 70 ++++++++++++++++++- .../preview/components/source-monitor.tsx | 50 +++++++------ .../preview/deps/settings-contract.ts | 1 + .../timeline-item/item-context-menu.test.tsx | 11 ++- .../timeline-item/item-context-menu.tsx | 12 ++-- 5 files changed, 117 insertions(+), 27 deletions(-) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index dbd2d8468..303ee2c10 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -63,6 +63,21 @@ const clockState = vi.hoisted(() => ({ playbackRate: 1, })) +const resolvedHotkeysState = vi.hoisted(() => ({ + hotkeys: { + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + GO_TO_START: 'home', + PREVIOUS_FRAME: 'left', + PLAY_PAUSE: 'space', + NEXT_FRAME: 'right', + GO_TO_END: 'end', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', + }, +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, @@ -133,7 +148,10 @@ vi.mock('@/features/preview/deps/settings', () => { { getState: () => settingsState }, ) - return { useSettingsStore } + return { + useSettingsStore, + useResolvedHotkeys: () => resolvedHotkeysState.hotkeys, + } }) vi.mock('@/shared/state/editor', () => { @@ -195,6 +213,56 @@ describe('SourceMonitor current media ownership', () => { editorStoreState.sourcePreviewMediaId = 'media-1' clockState.currentFrame = 0 clockState.isPlaying = false + resolvedHotkeysState.hotkeys = { + MARK_IN: 'i', + MARK_OUT: 'o', + CLEAR_IN_OUT: 'alt+x', + GO_TO_START: 'home', + PREVIOUS_FRAME: 'left', + PLAY_PAUSE: 'space', + NEXT_FRAME: 'right', + GO_TO_END: 'end', + INSERT_EDIT: 'comma', + OVERWRITE_EDIT: 'period', + } + }) + + it('updates visible shortcut labels after remap and reset', async () => { + const rendered = render() + + await waitFor(() => expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument()) + + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'shift+f', + } + rendered.rerender() + expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() + + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'i', + } + rendered.rerender() + expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() + }) + + it('uses macOS modifier names in visible shortcut labels', async () => { + const originalPlatform = navigator.platform + Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + CLEAR_IN_OUT: 'alt+x', + } + + try { + const rendered = render() + await waitFor(() => + expect(rendered.getByLabelText('Clear In/Out (Option + X)')).toBeInTheDocument(), + ) + } finally { + Object.defineProperty(navigator, 'platform', { configurable: true, value: originalPlatform }) + } }) it('does not release the current media during the initial Strict Mode remount', async () => { diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 1063dcd78..a98423241 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -70,6 +70,8 @@ import { import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' +import { formatHotkeyBinding } from '@/config/hotkeys' +import { useResolvedHotkeys } from '@/features/preview/deps/settings' interface SourceMonitorProps { mediaId: string @@ -205,6 +207,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ }: SourceMonitorProps) { const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) + const hotkeys = useResolvedHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -253,6 +256,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const mediaWidth = media.width || 640 const mediaHeight = media.height || 360 const durationInFrames = mediaType === 'image' ? 1 : Math.max(1, Math.round(media.duration * fps)) + const shortcutLabel = (binding: string) => formatHotkeyBinding(binding) return ( @@ -544,6 +548,7 @@ function SourceMonitorInner({ hasAudio={hasAudio} interactive={interactive} seekFrame={seekFrame} + hotkeys={hotkeys} /> ) @@ -558,6 +563,7 @@ function SourcePlaybackControls({ hasAudio, interactive, seekFrame, + hotkeys, }: { durationInFrames: number fps: number @@ -565,6 +571,7 @@ function SourcePlaybackControls({ hasAudio: boolean interactive: boolean seekFrame: number | null + hotkeys: ReturnType }) { const clock = useClock() const player = usePlayer(durationInFrames) @@ -587,6 +594,7 @@ function SourcePlaybackControls({ const currentTimeRef = useRef(null) const outPointRef = useRef(useSourcePlayerStore.getState().outPoint) const [showFrames, setShowFrames] = useState(false) + const shortcutLabel = (binding: string) => formatHotkeyBinding(binding) const showFramesRef = useRef(showFrames) showFramesRef.current = showFrames @@ -1289,12 +1297,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleMarkIn} - aria-label="Mark In (I)" + aria-label={`Mark In (${shortcutLabel(hotkeys.MARK_IN)})`} > - Mark In (I) + Mark In ({shortcutLabel(hotkeys.MARK_IN)}) @@ -1306,12 +1314,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleMarkOut} - aria-label="Mark Out (O)" + aria-label={`Mark Out (${shortcutLabel(hotkeys.MARK_OUT)})`} > - Mark Out (O) + Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) @@ -1323,12 +1331,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleClearIO} - aria-label="Clear In/Out (Alt+X)" + aria-label={`Clear In/Out (${shortcutLabel(hotkeys.CLEAR_IN_OUT)})`} > - Clear In/Out (Alt+X) + Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)}) )} @@ -1371,12 +1379,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleGoToStart} - aria-label="Go to start (Home)" + aria-label={`Go to start (${shortcutLabel(hotkeys.GO_TO_START)})`} > - Go to start (Home) + Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) @@ -1388,12 +1396,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleStepBack} - aria-label="Previous frame (Left Arrow)" + aria-label={`Previous frame (${shortcutLabel(hotkeys.PREVIOUS_FRAME)})`} > - Previous frame (Left Arrow) + Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) @@ -1404,7 +1412,7 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleTogglePlayback} - aria-label={playing ? 'Pause (Space)' : 'Play (Space)'} + aria-label={`${playing ? 'Pause' : 'Play'} (${shortcutLabel(hotkeys.PLAY_PAUSE)})`} > {playing ? ( @@ -1413,7 +1421,9 @@ function SourcePlaybackControls({ )} - {playing ? 'Pause' : 'Play'} (Space) + + {playing ? 'Pause' : 'Play'} ({shortcutLabel(hotkeys.PLAY_PAUSE)}) + @@ -1425,12 +1435,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleStepForward} - aria-label="Next frame (Right Arrow)" + aria-label={`Next frame (${shortcutLabel(hotkeys.NEXT_FRAME)})`} > - Next frame (Right Arrow) + Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) @@ -1442,12 +1452,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={handleGoToEnd} - aria-label="Go to end (End)" + aria-label={`Go to end (${shortcutLabel(hotkeys.GO_TO_END)})`} > - Go to end (End) + Go to end ({shortcutLabel(hotkeys.GO_TO_END)}) @@ -1545,12 +1555,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={() => performInsertEdit()} - aria-label="Insert (,)" + aria-label={`Insert (${shortcutLabel(hotkeys.INSERT_EDIT)})`} > - Insert (,) + Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) @@ -1562,12 +1572,12 @@ function SourcePlaybackControls({ height: EDITOR_LAYOUT_CSS_VALUES.toolbarButtonSize, }} onClick={() => performOverwriteEdit()} - aria-label="Overwrite (.)" + aria-label={`Overwrite (${shortcutLabel(hotkeys.OVERWRITE_EDIT)})`} > - Overwrite (.) + Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)}) ) : ( diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index 75f2cb318..7f300ac99 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,3 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index c67971c01..3b39ed0d8 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -48,12 +48,21 @@ vi.mock('@/features/timeline/deps/analysis', () => ({ vi.mock('@/features/timeline/deps/settings', () => ({ useResolvedHotkeys: () => ({ + JOIN_ITEMS: 'shift+j', + FREEZE_FRAME: 'shift+f', + DELETE_SELECTED: 'delete', RIPPLE_DELETE: 'mod+backspace', }), })) vi.mock('@/config/hotkeys', () => ({ - formatHotkeyBinding: (binding: string) => (binding === 'mod+backspace' ? 'Ctrl + Backspace' : ''), + formatHotkeyBinding: (binding: string) => + ({ + 'mod+backspace': 'Ctrl + Backspace', + 'shift+j': 'Shift + J', + 'shift+f': 'Shift + F', + delete: 'Delete', + })[binding] ?? '', })) function renderContextMenu(overrides: Partial> = {}) { diff --git a/src/features/timeline/components/timeline-item/item-context-menu.tsx b/src/features/timeline/components/timeline-item/item-context-menu.tsx index daca97a06..71c11c0b2 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.tsx @@ -395,6 +395,7 @@ function GradeActions({ t }: { t: ReturnType['t'] }) { function JoinActions({ t, + hotkeys, canJoinSelected, hasJoinableLeft, hasJoinableRight, @@ -414,19 +415,19 @@ function JoinActions({ {showJoinLeft && ( {t('timeline.contextMenu.joinWithPrevious')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} {showJoinRight && ( {t('timeline.contextMenu.joinWithNext')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} {canJoinSelected && ( {t('timeline.contextMenu.joinSelected')} - J + {formatHotkeyBinding(hotkeys.JOIN_ITEMS)} )} @@ -513,6 +514,7 @@ function LayoutActions({ t, selectedCount, onBentoLayout }: LayoutActionsProps) function MediaActions({ t, + hotkeys, canReverse, isReversed, isVideoItem, @@ -542,7 +544,7 @@ function MediaActions({ <> {t('timeline.contextMenu.insertFreezeFrame')} - Shift+F + {formatHotkeyBinding(hotkeys.FREEZE_FRAME)} @@ -731,7 +733,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('common.delete')} - Del + {formatHotkeyBinding(hotkeys.DELETE_SELECTED)} )} From 172f11cb26e47bea151f3b46761bc8c52ac322b2 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:25:10 -0700 Subject: [PATCH 04/21] fix: resolve duplicate shortcut bindings --- src/config/hotkeys.test.ts | 62 ++++++++++- src/config/hotkeys.ts | 102 ++++++++++++++++-- .../editor/host/shortcut-settings.test.ts | 35 ++++++ src/features/editor/host/shortcut-settings.ts | 33 ++++-- .../settings/components/hotkey-editor.tsx | 5 + .../settings/stores/settings-store.ts | 51 +++++---- 6 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 28e53c697..f63556ec7 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -13,6 +13,7 @@ import { getHotkeyPrimaryTokenFromEventData, normalizeHotkeyBinding, parseHotkeyImportDocument, + resolveHotkeyConfiguration, resolveHotkeys, sanitizeHotkeyOverrides, } from './hotkeys' @@ -150,6 +151,52 @@ describe('findHotkeyConflicts', () => { }) }) +describe('resolveHotkeyConfiguration', () => { + it('keeps every runtime binding unique and falls back a conflicting override', () => { + const result = resolveHotkeyConfiguration({ EDIT_KEYFRAME_ADD: 'k' }) + + expect(result.bindings.SHUTTLE_PAUSE).toBe('k') + expect(result.bindings.EDIT_KEYFRAME_ADD).toBe('shift+k') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual([ + { + code: 'duplicate_binding', + command: 'EDIT_KEYFRAME_ADD', + binding: 'k', + resolution: 'fallback', + conflictingCommand: 'SHUTTLE_PAUSE', + }, + ]) + }) + + it('rejects an earlier override instead of disabling a later default command', () => { + const result = resolveHotkeyConfiguration({ PLAY_PAUSE: 'k' }) + + expect(result.bindings.PLAY_PAUSE).toBe('space') + expect(result.bindings.SHUTTLE_PAUSE).toBe('k') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual([ + expect.objectContaining({ + command: 'PLAY_PAUSE', + conflictingCommand: 'SHUTTLE_PAUSE', + resolution: 'fallback', + }), + ]) + }) + + it('accepts a conflict-free swap regardless of canonical command order', () => { + const result = resolveHotkeyConfiguration({ + PLAY_PAUSE: 'k', + SHUTTLE_PAUSE: 'space', + }) + + expect(result.bindings.PLAY_PAUSE).toBe('k') + expect(result.bindings.SHUTTLE_PAUSE).toBe('space') + expect(result.overrides).toEqual({ PLAY_PAUSE: 'k', SHUTTLE_PAUSE: 'space' }) + expect(result.warnings).toEqual([]) + }) +}) + describe('sanitizeHotkeyOverrides', () => { it('keeps only supported commands with normalized non-default bindings', () => { expect( @@ -384,7 +431,7 @@ describe('parseHotkeyImportDocument', () => { }) }) - it('preserves an intentional plain-K keyframe override in a v2 preset', () => { + it('falls back a v2 plain-K keyframe override that conflicts with transport', () => { expect( parseHotkeyImportDocument({ schema: HOTKEY_EXPORT_SCHEMA, @@ -394,13 +441,20 @@ describe('parseHotkeyImportDocument', () => { }, }), ).toEqual({ - overrides: { - EDIT_KEYFRAME_ADD: 'k', - }, + overrides: {}, importedCommandCount: 1, ignoredCommandCount: 0, remappedCommandCount: 0, sourceVersion: 2, + conflictWarnings: [ + { + code: 'duplicate_binding', + command: 'EDIT_KEYFRAME_ADD', + binding: 'k', + resolution: 'fallback', + conflictingCommand: 'SHUTTLE_PAUSE', + }, + ], }) }) }) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 9cc74b8bd..f4fbeb101 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -139,6 +139,21 @@ export interface HotkeyImportResult { ignoredCommandCount: number remappedCommandCount: number sourceVersion: number | null + conflictWarnings?: HotkeyConflictWarning[] +} + +export interface HotkeyConflictWarning { + code: 'duplicate_binding' + command: HotkeyKey + binding: string + resolution: 'fallback' | 'unassigned' + conflictingCommand: HotkeyKey +} + +export interface HotkeyResolution { + bindings: HotkeyBindingMap + overrides: HotkeyOverrideMap + warnings: HotkeyConflictWarning[] } export interface BrowserHostileHotkey { @@ -405,11 +420,69 @@ function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { : 'windows' } -export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { - return { - ...HOTKEYS, - ...sanitizeHotkeyOverrides(overrides), +export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResolution { + const requested = normalizeHotkeyOverrides(overrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] + const rejectedOverrides = new Set() + let bindings = {} as HotkeyBindingMap + const effectiveOverrides: HotkeyOverrideMap = {} + const warnings: HotkeyConflictWarning[] = [] + + // Resolve the complete candidate map before assigning priority. This accepts + // valid swaps (for example Space <-> K), while any remaining collision rejects + // the participating custom binding(s) back to their unique canonical defaults. + // Re-run because one fallback can expose a collision with another custom value. + while (true) { + bindings = Object.fromEntries( + commandKeys.map((key) => [ + key, + !rejectedOverrides.has(key) && key in requested ? requested[key]! : HOTKEYS[key], + ]), + ) as HotkeyBindingMap + + const conflicts = Object.values(getHotkeyConflictMap(bindings)).filter( + (commands) => commands.length > 1, + ) + if (conflicts.length === 0) break + + let rejectedInPass = false + for (const commands of conflicts) { + for (const key of commands) { + if (rejectedOverrides.has(key) || !(key in requested)) continue + + const requestedBinding = requested[key]! + if (requestedBinding === HOTKEYS[key]) continue + + const conflictingCommand = commands.find((command) => command !== key)! + rejectedOverrides.add(key) + warnings.push({ + code: 'duplicate_binding', + command: key, + binding: normalizeHotkeyBinding(requestedBinding), + resolution: 'fallback', + conflictingCommand, + }) + rejectedInPass = true + } + } + + if (!rejectedInPass) { + throw new Error('Default keyboard shortcut bindings must be unique') + } } + + for (const key of commandKeys) { + const binding = bindings[key] + if (binding !== HOTKEYS[key]) { + effectiveOverrides[key] = binding + } + } + + return { bindings, overrides: effectiveOverrides, warnings } +} + +export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { + return resolveHotkeyConfiguration(overrides).bindings } function isExplicitlyUnassignedHotkey(rawBinding: string): boolean { @@ -531,6 +604,10 @@ export function normalizeHotkeyBinding(binding: string): string { } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { + return resolveHotkeyConfiguration(overrides).overrides +} + +function normalizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { if (!overrides || typeof overrides !== 'object') { return {} } @@ -785,12 +862,14 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { } } + const resolution = resolveHotkeyConfiguration(normalizedOverrides) return { - overrides: normalizedOverrides, + overrides: resolution.overrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion: null, + ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), } } @@ -801,7 +880,14 @@ function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResu ) { const overrides = { ...result.overrides } delete overrides.EDIT_KEYFRAME_ADD - return { ...result, overrides } + const conflictWarnings = result.conflictWarnings?.filter( + (warning) => warning.command !== 'EDIT_KEYFRAME_ADD', + ) + return { + ...result, + overrides, + ...(conflictWarnings && conflictWarnings.length > 0 ? { conflictWarnings } : {}), + } } return result @@ -874,12 +960,14 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { } } + const resolution = resolveHotkeyConfiguration(importedOverrides) return migrateLegacyHotkeyImport({ - overrides: sanitizeHotkeyOverrides(importedOverrides), + overrides: resolution.overrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion, + ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), }) } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index ce3a062c5..37ad672d4 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -3,6 +3,9 @@ import { createElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, waitFor } from '@testing-library/react' +import { useHotkeys } from 'react-hotkeys-hook' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { useResolvedHotkeys } from '@/features/editor/deps/settings' import { useSettingsStore } from '@/features/editor/deps/settings' import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' import { usePlaybackStore } from '@/shared/state/playback' @@ -14,6 +17,13 @@ function HostShortcutHarness() { return null } +function ConflictingShortcutHarness({ onAddKeyframe }: { onAddKeyframe: () => void }) { + const hotkeys = useResolvedHotkeys() + useHotkeys(hotkeys.EDIT_KEYFRAME_ADD, onAddKeyframe, HOTKEY_OPTIONS, [onAddKeyframe]) + useHostTimelineShortcuts() + return null +} + function createShortcutHost(initial: HostShortcutSettings) { const listeners = new Set<(settings: HostShortcutSettings) => void>() const setSettings = vi.fn() @@ -175,4 +185,29 @@ describe('host shortcut settings round trip', () => { unmount() expect(host.listenerCount()).toBe(0) }) + + it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => { + const harness = createShortcutHost({ + schema: 'freecut-host-shortcuts', + version: 1, + overrides: { + SHUTTLE_PAUSE: 'k', + EDIT_KEYFRAME_ADD: 'k', + }, + }) + const unmount = await mountHostShortcutSettings(harness.host) + const addKeyframe = vi.fn() + + render(createElement(ConflictingShortcutHarness, { onAddKeyframe: addKeyframe })) + usePlaybackStore.setState({ isPlaying: true }) + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) + expect(usePlaybackStore.getState().isPlaying).toBe(false) + expect(addKeyframe).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'K', code: 'KeyK', shiftKey: true }) + expect(addKeyframe).toHaveBeenCalledTimes(1) + expect(harness.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'conflict' })) + + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index a7685a1f6..5ec0e85d6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -1,4 +1,8 @@ -import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { + resolveHotkeyConfiguration, + type HotkeyConflictWarning, + type HotkeyOverrideMap, +} from '@/config/hotkeys' import { useSettingsStore } from '@/features/editor/deps/settings' import { HOST_SHORTCUTS_SCHEMA, @@ -8,12 +12,19 @@ import { type HostShortcutSettings, } from './contract' -function normalizeHostShortcutSettings(settings: HostShortcutSettings): HostShortcutSettings { +function normalizeHostShortcutSettings(settings: HostShortcutSettings): { + settings: HostShortcutSettings + warnings: HotkeyConflictWarning[] +} { if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) { throw new Error('Unsupported host shortcut settings schema') } - return createHostShortcutSettings(sanitizeHotkeyOverrides(settings.overrides)) + const resolution = resolveHotkeyConfiguration(settings.overrides) + return { + settings: createHostShortcutSettings(resolution.overrides), + warnings: resolution.warnings, + } } function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { @@ -50,8 +61,7 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => let inboundRevision = 0 let writeQueue = Promise.resolve() - const isCurrent = () => - !disposed && currentOwnership?.epoch === ownership.epoch + const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) @@ -61,9 +71,20 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) inboundRevision += 1 + if (normalized.warnings.length > 0) { + for (const warning of normalized.warnings) { + host.notify?.({ + kind: 'conflict', + message: + warning.resolution === 'fallback' + ? `Shortcut conflict for ${warning.command}; using its default binding.` + : `Shortcut conflict for ${warning.command}; the binding was disabled.`, + }) + } + } applyingHostSettings = true try { - useSettingsStore.getState().replaceHotkeyOverrides(normalized.overrides) + useSettingsStore.getState().replaceHotkeyOverrides(normalized.settings.overrides) } finally { applyingHostSettings = false } diff --git a/src/features/settings/components/hotkey-editor.tsx b/src/features/settings/components/hotkey-editor.tsx index b2462147b..b198efb37 100644 --- a/src/features/settings/components/hotkey-editor.tsx +++ b/src/features/settings/components/hotkey-editor.tsx @@ -936,6 +936,11 @@ export function HotkeyEditor() { try { const contents = await readTextFile(file) const importResult = parseHotkeyImportDocument(JSON.parse(contents)) + if (importResult.conflictWarnings?.length) { + toast.warning( + `${importResult.conflictWarnings.length} imported shortcut conflict(s) were resolved to keep commands reachable.`, + ) + } const changes = buildImportChanges(hotkeys, importResult.overrides) if (changes.length === 0) { diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index 1afa70f9a..5170fa21b 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -12,6 +12,7 @@ import { DEFAULT_EDITOR_DENSITY_PRESET, normalizeEditorDensityPreset } from '@/c import { HOTKEYS, normalizeHotkeyBinding, + resolveHotkeyConfiguration, sanitizeHotkeyOverrides, type HotkeyKey, type HotkeyOverrideMap, @@ -219,39 +220,36 @@ export const useSettingsStore = create()( set((state) => { const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || normalizedBinding === HOTKEYS[key]) { - if (!(key in state.hotkeyOverrides)) { - return state - } - const remainingOverrides = { ...state.hotkeyOverrides } delete remainingOverrides[key] - return { hotkeyOverrides: remainingOverrides } + const resolved = resolveHotkeyConfiguration(remainingOverrides).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } } - if (state.hotkeyOverrides[key] === normalizedBinding) { + const resolution = resolveHotkeyConfiguration({ + ...state.hotkeyOverrides, + [key]: normalizedBinding, + }) + const nextOverrides = resolution.overrides + + if (areHotkeyOverridesEqual(state.hotkeyOverrides, nextOverrides)) { return state } - return { - hotkeyOverrides: { - ...state.hotkeyOverrides, - [key]: normalizedBinding, - }, - } + return { hotkeyOverrides: nextOverrides } }), unbindHotkeyBinding: (key) => set((state) => { - if (state.hotkeyOverrides[key] === '') { - return state - } - - return { - hotkeyOverrides: { - ...state.hotkeyOverrides, - [key]: '', - }, - } + const resolved = resolveHotkeyConfiguration({ + ...state.hotkeyOverrides, + [key]: '', + }).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } }), replaceHotkeyOverrides: (overrides) => @@ -267,13 +265,12 @@ export const useSettingsStore = create()( resetHotkeyBinding: (key) => set((state) => { - if (!(key in state.hotkeyOverrides)) { - return state - } - const remainingOverrides = { ...state.hotkeyOverrides } delete remainingOverrides[key] - return { hotkeyOverrides: remainingOverrides } + const resolved = resolveHotkeyConfiguration(remainingOverrides).overrides + return areHotkeyOverridesEqual(state.hotkeyOverrides, resolved) + ? state + : { hotkeyOverrides: resolved } }), resetHotkeys: () => From 830fd0369d8caee82c85405bfc7be89068d7f323 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:22:31 -0700 Subject: [PATCH 05/21] fix(shortcuts): guard dialog control key events --- src/config/hotkeys-dom-guard.test.ts | 79 ++++++++++++++++++++++++++++ src/config/hotkeys.ts | 29 +++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/config/hotkeys-dom-guard.test.ts diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts new file mode 100644 index 000000000..d690403be --- /dev/null +++ b/src/config/hotkeys-dom-guard.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vite-plus/test' +import { shouldIgnoreGlobalHotkey } from './hotkeys' + +describe('global shortcut DOM guards', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + function dispatchFrom(markup: string, selector: string, key: string) { + document.body.innerHTML = markup + const target = document.querySelector(selector) + if (!(target instanceof HTMLElement)) throw new Error(`Missing ${selector}`) + + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + let captureSawEvent = false + const captureListener = (capturedEvent: KeyboardEvent) => { + captureSawEvent = true + if (!shouldIgnoreGlobalHotkey(capturedEvent)) capturedEvent.preventDefault() + } + document.addEventListener('keydown', captureListener, { capture: true }) + target.dispatchEvent(event) + document.removeEventListener('keydown', captureListener, { capture: true }) + return { captureSawEvent, defaultPrevented: event.defaultPrevented } + } + + it('still receives events in capture phase but does not handle contenteditable targets', () => { + const result = dispatchFrom( + '
text
', + '#editor', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it.each(['button', 'input', 'textarea', 'select'])('guards dialog %s controls', (tagName) => { + const result = dispatchFrom( + `
<${tagName} id="control">
`, + '#control', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it('allows an explicitly opted-in dialog control', () => { + const result = dispatchFrom( + '
', + '#control', + 'j', + ) + + expect(result).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }) + + it('preserves dialog K events without preventDefault or propagation swallowing', () => { + document.body.innerHTML = '
' + const target = document.querySelector('#control') as HTMLButtonElement + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true }) + const captureListener = (capturedEvent: KeyboardEvent) => { + if (!shouldIgnoreGlobalHotkey(capturedEvent)) { + capturedEvent.preventDefault() + capturedEvent.stopPropagation() + } + } + + document.addEventListener('keydown', captureListener, { capture: true }) + target.dispatchEvent(event) + document.removeEventListener('keydown', captureListener, { capture: true }) + document.body.removeEventListener('keydown', bubble) + + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index f4fbeb101..d0368d013 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -971,11 +971,38 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { }) } +const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' +const DIALOG_SELECTOR = '[role="dialog"], dialog' +const DIALOG_CONTROL_SELECTOR = + 'button, input, textarea, select, [role="button"], [contenteditable="true"], [contenteditable=""]' + +function isContentEditableTarget(target: Element): boolean { + const editable = target.closest('[contenteditable]') + return editable !== null && editable.getAttribute('contenteditable') !== 'false' +} + +/** + * Returns true when a global shortcut should be ignored for the focused DOM + * target. Ignoring here is intentional: react-hotkeys-hook then leaves the + * event alone, preserving dialog controls' default actions and propagation. + */ +export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { + const target = event.target + if (typeof Element === 'undefined' || !(target instanceof Element)) return false + if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false + if (isContentEditableTarget(target)) return true + + const dialog = target.closest(DIALOG_SELECTOR) + return dialog !== null && target.closest(DIALOG_CONTROL_SELECTOR) !== null +} + /** * Options for react-hotkeys-hook. - * Prevents shortcuts from firing in input fields. + * Prevents shortcuts from firing in editable fields and dialog controls. */ export const HOTKEY_OPTIONS = { enableOnFormTags: false, + enableOnContentEditable: false, + ignoreEventWhen: shouldIgnoreGlobalHotkey, preventDefault: true, } as const From 850e39008b4511b40bfa1e02fdcc6cc05f697caf Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:28:35 -0700 Subject: [PATCH 06/21] fix(shortcuts): align reactive authority and guards --- src/config/hotkeys-dom-guard.test.ts | 54 ++++++++++++++++++- src/config/hotkeys.ts | 41 ++++++++------ .../editor/host/shortcut-settings.test.ts | 16 +++--- .../components/source-monitor.test.tsx | 21 +++++++- .../preview/components/source-monitor.tsx | 48 +++++++++++------ 5 files changed, 138 insertions(+), 42 deletions(-) diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index d690403be..cb34ee8ae 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -1,7 +1,22 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vite-plus/test' -import { shouldIgnoreGlobalHotkey } from './hotkeys' +import { createElement } from 'react' +import { render, screen } from '@testing-library/react' +import { useHotkeys } from 'react-hotkeys-hook' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { HOTKEY_OPTIONS, shouldIgnoreGlobalHotkey } from './hotkeys' + +function CaptureHotkeyHarness({ onHotkey }: { onHotkey: () => void }) { + useHotkeys('k', onHotkey, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [ + onHotkey, + ]) + + return createElement( + 'div', + { role: 'dialog' }, + createElement('button', { type: 'button' }, 'Pause'), + ) +} describe('global shortcut DOM guards', () => { afterEach(() => { @@ -55,6 +70,19 @@ describe('global shortcut DOM guards', () => { expect(result).toEqual({ captureSawEvent: true, defaultPrevented: true }) }) + it.each([ + ['input', '
'], + [ + 'contenteditable', + '
', + ], + ])('allows explicitly opted-in %s targets', (_name, markup) => { + expect(dispatchFrom(markup, '#control', 'j')).toEqual({ + captureSawEvent: true, + defaultPrevented: true, + }) + }) + it('preserves dialog K events without preventDefault or propagation swallowing', () => { document.body.innerHTML = '
' const target = document.querySelector('#control') as HTMLButtonElement @@ -76,4 +104,26 @@ describe('global shortcut DOM guards', () => { expect(event.defaultPrevented).toBe(false) expect(bubble).toHaveBeenCalledTimes(1) }) + + it('keeps the real capture-phase hotkey listener inert for dialog K events', () => { + const onHotkey = vi.fn() + const rendered = render(createElement(CaptureHotkeyHarness, { onHotkey })) + const target = screen.getByRole('button', { name: 'Pause' }) + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + document.body.removeEventListener('keydown', bubble) + rendered.unmount() + expect(onHotkey).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index d0368d013..099de6b3c 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -862,29 +862,40 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { } } - const resolution = resolveHotkeyConfiguration(normalizedOverrides) return { - overrides: resolution.overrides, + overrides: normalizedOverrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion: null, + } +} + +function resolveHotkeyImportResult(result: HotkeyImportResult): HotkeyImportResult { + const resolution = resolveHotkeyConfiguration(result.overrides) + return { + ...result, + overrides: resolution.overrides, ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), } } function migrateLegacyHotkeyImport(result: HotkeyImportResult): HotkeyImportResult { - if ( - (result.sourceVersion === null || result.sourceVersion < 2) && - result.overrides.EDIT_KEYFRAME_ADD === 'k' - ) { + const hasLegacyKeyframeBinding = + result.overrides.EDIT_KEYFRAME_ADD === 'k' || + result.conflictWarnings?.some( + (warning) => warning.command === 'EDIT_KEYFRAME_ADD' && warning.binding === 'k', + ) + + if ((result.sourceVersion === null || result.sourceVersion < 2) && hasLegacyKeyframeBinding) { const overrides = { ...result.overrides } delete overrides.EDIT_KEYFRAME_ADD const conflictWarnings = result.conflictWarnings?.filter( (warning) => warning.command !== 'EDIT_KEYFRAME_ADD', ) + const { conflictWarnings: _discardedWarnings, ...resultWithoutWarnings } = result return { - ...result, + ...resultWithoutWarnings, overrides, ...(conflictWarnings && conflictWarnings.length > 0 ? { conflictWarnings } : {}), } @@ -899,7 +910,7 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { } if (source.schema !== HOTKEY_EXPORT_SCHEMA) { - return migrateLegacyHotkeyImport(collectImportedOverrides(source)) + return migrateLegacyHotkeyImport(resolveHotkeyImportResult(collectImportedOverrides(source))) } const sourceVersion = typeof source.version === 'number' ? source.version : null @@ -973,8 +984,7 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' const DIALOG_SELECTOR = '[role="dialog"], dialog' -const DIALOG_CONTROL_SELECTOR = - 'button, input, textarea, select, [role="button"], [contenteditable="true"], [contenteditable=""]' +const FORM_CONTROL_SELECTOR = 'input, textarea, select' function isContentEditableTarget(target: Element): boolean { const editable = target.closest('[contenteditable]') @@ -991,9 +1001,8 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { if (typeof Element === 'undefined' || !(target instanceof Element)) return false if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false if (isContentEditableTarget(target)) return true - - const dialog = target.closest(DIALOG_SELECTOR) - return dialog !== null && target.closest(DIALOG_CONTROL_SELECTOR) !== null + if (target.closest(FORM_CONTROL_SELECTOR)) return true + return target.closest(DIALOG_SELECTOR) !== null } /** @@ -1001,8 +1010,10 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { * Prevents shortcuts from firing in editable fields and dialog controls. */ export const HOTKEY_OPTIONS = { - enableOnFormTags: false, - enableOnContentEditable: false, + // Route normally excluded targets through ignoreEventWhen so the explicit + // data-global-hotkeys="allow" escape hatch works for those targets too. + enableOnFormTags: true, + enableOnContentEditable: true, ignoreEventWhen: shouldIgnoreGlobalHotkey, preventDefault: true, } as const diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 37ad672d4..dbb3aa10d 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -117,16 +117,16 @@ describe('host shortcut settings round trip', () => { harness.emit( createHostShortcutSettings({ - SHUTTLE_REVERSE: 'a', - SHUTTLE_PAUSE: 's', - SHUTTLE_FORWARD: 'd', + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', }), ) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - SHUTTLE_REVERSE: 'a', - SHUTTLE_PAUSE: 's', - SHUTTLE_FORWARD: 'd', + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', }) expect(harness.notify).not.toHaveBeenCalled() @@ -171,10 +171,10 @@ describe('host shortcut settings round trip', () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') - host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'y' })) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) await Promise.resolve() expect(host.setSettings).not.toHaveBeenCalled() - expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'y' }) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'w' }) unmount() }) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 303ee2c10..ce41d0bba 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -236,17 +236,34 @@ describe('SourceMonitor current media ownership', () => { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } - rendered.rerender() + rendered.rerender() expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() resolvedHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys, MARK_IN: 'i', } - rendered.rerender() + rendered.rerender() expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() }) + it('uses the same reactive binding for local source-monitor actions', async () => { + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'shift+f', + } + sourcePlayerStoreState.currentSourceFrame = 42 + const rendered = render() + await waitFor(() => expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument()) + const monitor = rendered.container.firstElementChild! + + fireEvent.keyDown(monitor, { key: 'i', code: 'KeyI' }) + expect(sourcePlayerStoreState.setInPoint).not.toHaveBeenCalled() + + fireEvent.keyDown(monitor, { key: 'F', code: 'KeyF', shiftKey: true }) + expect(sourcePlayerStoreState.setInPoint).toHaveBeenCalledWith(42) + }) + it('uses macOS modifier names in visible shortcut labels', async () => { const originalPlatform = navigator.platform Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index a98423241..0479fcd9d 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -70,7 +70,7 @@ import { import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' -import { formatHotkeyBinding } from '@/config/hotkeys' +import { formatHotkeyBinding, getHotkeyBindingFromEventData } from '@/config/hotkeys' import { useResolvedHotkeys } from '@/features/preview/deps/settings' interface SourceMonitorProps { @@ -256,8 +256,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const mediaWidth = media.width || 640 const mediaHeight = media.height || 360 const durationInFrames = mediaType === 'image' ? 1 : Math.max(1, Math.round(media.duration * fps)) - const shortcutLabel = (binding: string) => formatHotkeyBinding(binding) - return ( {}}> @@ -281,6 +279,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ interactive={interactive} seekFrame={seekFrame} onClose={onClose} + hotkeys={hotkeys} /> @@ -304,6 +303,7 @@ interface SourceMonitorInnerProps { interactive: boolean seekFrame: number | null onClose?: () => void + hotkeys: ReturnType } function SourceMonitorInner({ @@ -320,6 +320,7 @@ function SourceMonitorInner({ interactive, seekFrame, onClose, + hotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -450,23 +451,24 @@ function SourceMonitorInner({ (e: React.KeyboardEvent) => { if (!interactive) return if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + const binding = getHotkeyBindingFromEventData(e) const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (e.key === 'i' || e.key === 'I') { + if (binding === hotkeys.MARK_IN) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (e.key === 'o' || e.key === 'O') { + } else if (binding === hotkeys.MARK_OUT) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (e.altKey && (e.key === 'x' || e.key === 'X')) { + } else if (binding === hotkeys.CLEAR_IN_OUT) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, interactive], + [durationInFrames, hotkeys, interactive], ) const handleMouseEnter = useCallback(() => { @@ -1319,7 +1321,9 @@ function SourcePlaybackControls({ - Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) + + Mark Out ({shortcutLabel(hotkeys.MARK_OUT)}) + @@ -1336,7 +1340,9 @@ function SourcePlaybackControls({ - Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)}) + + Clear In/Out ({shortcutLabel(hotkeys.CLEAR_IN_OUT)}) + )} @@ -1384,7 +1390,9 @@ function SourcePlaybackControls({ - Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) + + Go to start ({shortcutLabel(hotkeys.GO_TO_START)}) + @@ -1401,7 +1409,9 @@ function SourcePlaybackControls({ - Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) + + Previous frame ({shortcutLabel(hotkeys.PREVIOUS_FRAME)}) + @@ -1440,7 +1450,9 @@ function SourcePlaybackControls({ - Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) + + Next frame ({shortcutLabel(hotkeys.NEXT_FRAME)}) + @@ -1457,7 +1469,9 @@ function SourcePlaybackControls({ - Go to end ({shortcutLabel(hotkeys.GO_TO_END)}) + + Go to end ({shortcutLabel(hotkeys.GO_TO_END)}) + @@ -1560,7 +1574,9 @@ function SourcePlaybackControls({ - Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) + + Insert ({shortcutLabel(hotkeys.INSERT_EDIT)}) + @@ -1577,7 +1593,9 @@ function SourcePlaybackControls({ - Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)}) + + Overwrite ({shortcutLabel(hotkeys.OVERWRITE_EDIT)}) + ) : ( From ebcad189683fa1a7fde44e862784b1ee4e14c326 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:30:48 -0700 Subject: [PATCH 07/21] fix(host): cancel deferred shortcut ownership --- src/features/editor/host/editor-surface.tsx | 8 ++- .../editor/host/shortcut-settings.test.ts | 44 ++++++++++++++ src/features/editor/host/shortcut-settings.ts | 60 ++++++++++++------- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index 7de9e01c8..4019e7483 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -35,11 +35,15 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { useEffect(() => { let cancelled = false let unmountShortcutSettings: (() => void) | undefined + const shortcutSettingsAbortController = new AbortController() setState(null) setError(null) const initialize = async () => { - unmountShortcutSettings = await mountHostShortcutSettings(host) + unmountShortcutSettings = await mountHostShortcutSettings( + host, + shortcutSettingsAbortController.signal, + ) if (cancelled) { unmountShortcutSettings() unmountShortcutSettings = undefined @@ -55,6 +59,7 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { void initialize() .then(() => undefined) .catch((caught) => { + shortcutSettingsAbortController.abort() unmountShortcutSettings?.() unmountShortcutSettings = undefined if (cancelled) return @@ -63,6 +68,7 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { return () => { cancelled = true + shortcutSettingsAbortController.abort() unmountShortcutSettings?.() } }, [host]) diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index dbb3aa10d..937d34df9 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -156,6 +156,50 @@ describe('host shortcut settings round trip', () => { unmountB() }) + it('invalidates deferred host A when replacement B omits the optional shortcut port', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + let resolveA!: (settings: HostShortcutSettings) => void + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + hostA.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveA = resolve)), + ) + const mountA = mountHostShortcutSettings(hostA.host) + const hostB = { ...createShortcutHost(createHostShortcutSettings({})).host } + delete hostB.shortcuts + + const unmountB = await mountHostShortcutSettings(hostB) + resolveA(createHostShortcutSettings({ SHUTTLE_REVERSE: 'a' })) + const unmountA = await mountA + + expect(hostA.listenerCount()).toBe(0) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + unmountA() + unmountB() + }) + + it('cancels deferred hydration on unmount before subscribing', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + let resolveSettings!: (settings: HostShortcutSettings) => void + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_REVERSE: 'q' })) + host.host.shortcuts!.getSettings = vi.fn( + () => new Promise((resolve) => (resolveSettings = resolve)), + ) + const controller = new AbortController() + const mounting = mountHostShortcutSettings(host.host, controller.signal) + + controller.abort() + resolveSettings(createHostShortcutSettings({ SHUTTLE_REVERSE: 'q' })) + const unmount = await mounting + + expect(host.listenerCount()).toBe(0) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', + }) + unmount() + }) + it('does not execute a queued write after its host is disposed', async () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index 5ec0e85d6..bb5e99f80 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -43,12 +43,10 @@ let currentOwnership: ShortcutOwnership | null = null * Hydrates host-owned shortcuts before the editor mounts, then keeps UI and * host/agent changes synchronized for the lifetime of the embedded surface. */ -export async function mountHostShortcutSettings(host: EditorHost): Promise<() => void> { - const port = host.shortcuts - if (!port) { - return () => undefined - } - +export async function mountHostShortcutSettings( + host: EditorHost, + signal?: AbortSignal, +): Promise<() => void> { const ownership: ShortcutOwnership = { epoch: ++nextOwnershipEpoch, standaloneOverrides: copyOverrides( @@ -60,9 +58,39 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => let disposed = false let inboundRevision = 0 let writeQueue = Promise.resolve() + let unsubscribeHost: (() => void) | undefined + let unsubscribeStore: (() => void) | undefined const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch + const dispose = () => { + if (disposed) return + disposed = true + inboundRevision += 1 + unsubscribeStore?.() + unsubscribeHost?.() + signal?.removeEventListener('abort', dispose) + if (currentOwnership?.epoch !== ownership.epoch) return + currentOwnership = null + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) + } + + if (signal?.aborted) { + dispose() + return dispose + } + signal?.addEventListener('abort', dispose, { once: true }) + + // Replacing a host invalidates the previous epoch immediately, including + // while either host is still resolving getSettings. Keep the standalone + // snapshot visible until this owner has authoritative settings to apply. + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) + + const port = host.shortcuts + if (!port) { + return dispose + } + const reportFailure = (message: string) => { host.notify?.({ kind: 'error', message }) } @@ -94,15 +122,15 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => try { initialSettings = await Promise.resolve(port.getSettings()) } catch (error) { - if (currentOwnership?.epoch === ownership.epoch) currentOwnership = null + dispose() throw error } if (!isCurrent()) { - return () => undefined + return dispose } applyHostSettings(initialSettings) - const unsubscribeHost = port.subscribe?.((settings) => { + unsubscribeHost = port.subscribe?.((settings) => { if (!isCurrent()) return try { applyHostSettings(settings) @@ -111,7 +139,7 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => } }) - const unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { + unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { if ( disposed || applyingHostSettings || @@ -128,17 +156,9 @@ export async function mountHostShortcutSettings(host: EditorHost): Promise<() => return Promise.resolve(port.setSettings(settings)) }) .catch(() => { - reportFailure('Could not save keyboard shortcuts to the host.') + if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') }) }) - return () => { - disposed = true - inboundRevision += 1 - unsubscribeStore() - unsubscribeHost?.() - if (currentOwnership?.epoch !== ownership.epoch) return - currentOwnership = null - useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) - } + return dispose } From d7a400cbdfcc5c87536ce55b77de5b5ee18b94d9 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:35:20 -0700 Subject: [PATCH 08/21] test(shortcuts): enforce unique imported bindings --- .../hotkey-editor-reset-dialog.test.tsx | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx index 0099dff4f..bb3124215 100644 --- a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx +++ b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx @@ -120,34 +120,28 @@ describe('HotkeyEditor reset all confirmation', () => { }) }) - it('restores partial conflict overwrites when capture is cancelled', async () => { - useSettingsStore.setState({ - hotkeyOverrides: { - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', - }, + it('repairs duplicate overrides before presenting conflict choices', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + PLAY_PAUSE: 'shift+space', + PREVIOUS_FRAME: 'right', + }) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + PLAY_PAUSE: 'shift+space', }) click(getButton('Record')) await waitForText('Listening') keyDown('ArrowRight', 'ArrowRight') - await waitForBodyText('Conflicts with Previous frame') await waitForBodyText('Conflicts with Next frame') - click(getButton('Overwrite')) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(useSettingsStore.getState().hotkeyOverrides).not.toEqual({ - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', - }) + expect(document.body.textContent).not.toContain('Conflicts with Previous frame') keyDown('Escape', 'Escape') await new Promise((resolve) => setTimeout(resolve, 0)) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - PLAY_PAUSE: 'shift+k', - PREVIOUS_FRAME: 'right', + PLAY_PAUSE: 'shift+space', }) }) From d514f1909be62d568ac80eb0ebab9df2d1d11729 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:41:03 -0700 Subject: [PATCH 09/21] refactor(shortcuts): keep conflict resolution bounded --- src/config/hotkeys.ts | 99 ++++++++++--------- src/features/editor/host/contract.ts | 4 +- .../settings/stores/settings-store.ts | 5 +- 3 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 099de6b3c..ce91fa8de 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -421,11 +421,10 @@ function getHotkeyPlatform(platformValue?: string): HotkeyPlatform { } export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResolution { - const requested = normalizeHotkeyOverrides(overrides) + const requested = sanitizeHotkeyOverrides(overrides) const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] const rejectedOverrides = new Set() - let bindings = {} as HotkeyBindingMap - const effectiveOverrides: HotkeyOverrideMap = {} + let bindings = createResolvedHotkeyBindings(commandKeys, requested, rejectedOverrides) const warnings: HotkeyConflictWarning[] = [] // Resolve the complete candidate map before assigning priority. This accepts @@ -433,52 +432,62 @@ export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResol // the participating custom binding(s) back to their unique canonical defaults. // Re-run because one fallback can expose a collision with another custom value. while (true) { - bindings = Object.fromEntries( - commandKeys.map((key) => [ - key, - !rejectedOverrides.has(key) && key in requested ? requested[key]! : HOTKEYS[key], - ]), - ) as HotkeyBindingMap - - const conflicts = Object.values(getHotkeyConflictMap(bindings)).filter( - (commands) => commands.length > 1, - ) + const conflicts = getDuplicateHotkeyCommandGroups(bindings) if (conflicts.length === 0) break - let rejectedInPass = false - for (const commands of conflicts) { - for (const key of commands) { - if (rejectedOverrides.has(key) || !(key in requested)) continue - - const requestedBinding = requested[key]! - if (requestedBinding === HOTKEYS[key]) continue - - const conflictingCommand = commands.find((command) => command !== key)! - rejectedOverrides.add(key) - warnings.push({ - code: 'duplicate_binding', - command: key, - binding: normalizeHotkeyBinding(requestedBinding), - resolution: 'fallback', - conflictingCommand, - }) - rejectedInPass = true - } - } - - if (!rejectedInPass) { + const passWarnings = createConflictFallbackWarnings(conflicts, requested, rejectedOverrides) + if (passWarnings.length === 0) { throw new Error('Default keyboard shortcut bindings must be unique') } + for (const warning of passWarnings) rejectedOverrides.add(warning.command) + warnings.push(...passWarnings) + bindings = createResolvedHotkeyBindings(commandKeys, requested, rejectedOverrides) } - for (const key of commandKeys) { - const binding = bindings[key] - if (binding !== HOTKEYS[key]) { - effectiveOverrides[key] = binding - } - } + return { bindings, overrides: getEffectiveHotkeyOverrides(bindings), warnings } +} + +function createResolvedHotkeyBindings( + commandKeys: HotkeyKey[], + requested: HotkeyOverrideMap, + rejected: Set, +): HotkeyBindingMap { + return Object.fromEntries( + commandKeys.map((key) => [ + key, + !rejected.has(key) && key in requested ? requested[key]! : HOTKEYS[key], + ]), + ) as HotkeyBindingMap +} - return { bindings, overrides: effectiveOverrides, warnings } +function getDuplicateHotkeyCommandGroups(bindings: HotkeyBindingMap): HotkeyKey[][] { + return Object.values(getHotkeyConflictMap(bindings)).filter((commands) => commands.length > 1) +} + +function createConflictFallbackWarnings( + conflicts: HotkeyKey[][], + requested: HotkeyOverrideMap, + rejected: Set, +): HotkeyConflictWarning[] { + return conflicts.flatMap((commands) => + commands + .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) + .map((key) => ({ + code: 'duplicate_binding' as const, + command: key, + binding: normalizeHotkeyBinding(requested[key]!), + resolution: 'fallback' as const, + conflictingCommand: commands.find((command) => command !== key)!, + })), + ) +} + +function getEffectiveHotkeyOverrides(bindings: HotkeyBindingMap): HotkeyOverrideMap { + return Object.fromEntries( + (Object.keys(HOTKEYS) as HotkeyKey[]) + .filter((key) => bindings[key] !== HOTKEYS[key]) + .map((key) => [key, bindings[key]]), + ) } export function resolveHotkeys(overrides: HotkeyOverrideMap = {}): HotkeyBindingMap { @@ -604,10 +613,6 @@ export function normalizeHotkeyBinding(binding: string): string { } export function sanitizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { - return resolveHotkeyConfiguration(overrides).overrides -} - -function normalizeHotkeyOverrides(overrides: unknown): HotkeyOverrideMap { if (!overrides || typeof overrides !== 'object') { return {} } @@ -779,7 +784,7 @@ export function findHotkeyConflicts( export function createHotkeyExportDocument( overrides: HotkeyOverrideMap = {}, ): HotkeyExportDocument { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides const bindings = resolveHotkeys(normalizedOverrides) const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index de38cf132..f8e04224b 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,7 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' -import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' +import { resolveHotkeyConfiguration, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -308,7 +308,7 @@ export function createHostShortcutSettings( return { schema: HOST_SHORTCUTS_SCHEMA, version: HOST_SHORTCUTS_VERSION, - overrides: sanitizeHotkeyOverrides(overrides), + overrides: resolveHotkeyConfiguration(overrides).overrides, } } diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index 5170fa21b..a4f9d4ea0 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -13,7 +13,6 @@ import { HOTKEYS, normalizeHotkeyBinding, resolveHotkeyConfiguration, - sanitizeHotkeyOverrides, type HotkeyKey, type HotkeyOverrideMap, } from '@/config/hotkeys' @@ -254,7 +253,7 @@ export const useSettingsStore = create()( replaceHotkeyOverrides: (overrides) => set((state) => { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides if (areHotkeyOverridesEqual(state.hotkeyOverrides, normalizedOverrides)) { return state @@ -315,7 +314,7 @@ export const useSettingsStore = create()( ...currentState, ...typedState, defaultWhisperModel: normalizeSelectableWhisperModel(typedState.defaultWhisperModel), - hotkeyOverrides: sanitizeHotkeyOverrides(typedState.hotkeyOverrides), + hotkeyOverrides: resolveHotkeyConfiguration(typedState.hotkeyOverrides).overrides, editorDensity: normalizeEditorDensityPreset(typedState.editorDensity), captioningIntervalUnit, captioningIntervalValue: clampCaptioningIntervalValue( From 541085cd313f452de9038b9fd8a02ebcf6a4b70f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 26 Aug 2026 21:44:20 -0700 Subject: [PATCH 10/21] fix(host): surface shortcut conflict notices --- src/features/editor/host/contract.ts | 4 ++-- src/features/editor/host/shortcut-settings.test.ts | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index f8e04224b..de38cf132 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -7,7 +7,7 @@ import type { TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' -import { resolveHotkeyConfiguration, type HotkeyOverrideMap } from '@/config/hotkeys' +import { sanitizeHotkeyOverrides, type HotkeyOverrideMap } from '@/config/hotkeys' /** * The browser surface is deliberately a port. It knows how to render and @@ -308,7 +308,7 @@ export function createHostShortcutSettings( return { schema: HOST_SHORTCUTS_SCHEMA, version: HOST_SHORTCUTS_VERSION, - overrides: resolveHotkeyConfiguration(overrides).overrides, + overrides: sanitizeHotkeyOverrides(overrides), } } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 937d34df9..2e104e1ef 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -231,14 +231,12 @@ describe('host shortcut settings round trip', () => { }) it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => { - const harness = createShortcutHost({ - schema: 'freecut-host-shortcuts', - version: 1, - overrides: { + const harness = createShortcutHost( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'k', EDIT_KEYFRAME_ADD: 'k', - }, - }) + }), + ) const unmount = await mountHostShortcutSettings(harness.host) const addKeyframe = vi.fn() From 0f5602d9de745faa6186e17e915a125efe3e1f69 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 02:52:53 -0700 Subject: [PATCH 11/21] fix(shortcuts): reconcile runtime ownership --- src/config/hotkeys-dom-guard.test.ts | 111 ++++++++++++- src/config/hotkeys.test.ts | 44 ++++++ src/config/hotkeys.ts | 149 +++++++++++++++--- .../editor/host/shortcut-settings.test.ts | 121 ++++++++++++++ src/features/editor/host/shortcut-settings.ts | 102 +++++++++--- .../hotkey-editor-reset-dialog.test.tsx | 28 ++++ .../timeline-item/trim-handles.test.tsx | 47 +++++- .../components/timeline-item/trim-handles.tsx | 8 +- .../shortcuts/runtime-conflicts.test.tsx | 71 +++++++++ .../hooks/shortcuts/use-in-out-shortcuts.ts | 25 +-- 10 files changed, 637 insertions(+), 69 deletions(-) create mode 100644 src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index cb34ee8ae..8419e5721 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createElement } from 'react' +import { createElement, type ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { useHotkeys } from 'react-hotkeys-hook' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' @@ -18,6 +18,19 @@ function CaptureHotkeyHarness({ onHotkey }: { onHotkey: () => void }) { ) } +function GlobalCaptureHarness({ + onHotkey, + children, +}: { + onHotkey: () => void + children?: ReactNode +}) { + useHotkeys('k', onHotkey, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [ + onHotkey, + ]) + return children ?? null +} + describe('global shortcut DOM guards', () => { afterEach(() => { document.body.replaceChildren() @@ -60,6 +73,50 @@ describe('global shortcut DOM guards', () => { expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false }) }) + it.each([ + ['native button', ''], + ['native link', 'Project'], + ['summary', '
Details
'], + ['button role', '
Run
'], + ['menuitem role', ''], + ])('guards an interactive %s outside dialogs', (_name, markup) => { + expect(dispatchFrom(markup, '#control', 'k')).toEqual({ + captureSawEvent: true, + defaultPrevented: false, + }) + }) + + it('guards every dialog descendant, even when the target is a plain span', () => { + expect( + dispatchFrom('
Message
', '#control', 'j'), + ).toEqual({ captureSawEvent: true, defaultPrevented: false }) + }) + + it('uses the nearest contenteditable value for inherited editing and false islands', () => { + expect( + dispatchFrom( + '
text
', + '#editable', + 'j', + ), + ).toEqual({ captureSawEvent: true, defaultPrevented: false }) + + expect( + dispatchFrom( + '
clip
', + '#island', + 'j', + ), + ).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }) + + it('keeps ordinary canvas targets eligible for editor shortcuts', () => { + expect(dispatchFrom('', '#timeline', 'k')).toEqual({ + captureSawEvent: true, + defaultPrevented: true, + }) + }) + it('allows an explicitly opted-in dialog control', () => { const result = dispatchFrom( '
', @@ -126,4 +183,56 @@ describe('global shortcut DOM guards', () => { expect(event.defaultPrevented).toBe(false) expect(bubble).toHaveBeenCalledTimes(1) }) + + it('keeps the real capture listener inert on a native button without swallowing bubbling', () => { + const onHotkey = vi.fn() + const rendered = render( + createElement( + GlobalCaptureHarness, + { onHotkey }, + createElement('button', { type: 'button' }, 'Run'), + ), + ) + const target = screen.getByRole('button', { name: 'Run' }) + const bubble = vi.fn() + document.body.addEventListener('keydown', bubble) + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + document.body.removeEventListener('keydown', bubble) + rendered.unmount() + expect(onHotkey).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(bubble).toHaveBeenCalledTimes(1) + }) + + it('executes one real capture handler once on an ordinary canvas', () => { + const onHotkey = vi.fn() + const rendered = render( + createElement( + GlobalCaptureHarness, + { onHotkey }, + createElement('canvas', { 'aria-label': 'Timeline canvas' }), + ), + ) + const target = screen.getByLabelText('Timeline canvas') + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + cancelable: true, + }) + + target.dispatchEvent(event) + + rendered.unmount() + expect(onHotkey).toHaveBeenCalledTimes(1) + expect(event.defaultPrevented).toBe(true) + }) }) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index f63556ec7..7af7efecb 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -149,6 +149,12 @@ describe('findHotkeyConflicts', () => { expect(findHotkeyConflicts(bindings, 'c', 'SELECTION_TOOL')).toEqual(['RAZOR_TOOL']) }) + + it('exposes derived preview variants that collide with runtime commands', () => { + const bindings = resolveHotkeys() + + expect(findHotkeyConflicts(bindings, 'j', 'MARK_IN')).toContain('JOIN_ITEMS') + }) }) describe('resolveHotkeyConfiguration', () => { @@ -195,6 +201,44 @@ describe('resolveHotkeyConfiguration', () => { expect(result.overrides).toEqual({ PLAY_PAUSE: 'k', SHUTTLE_PAUSE: 'space' }) expect(result.warnings).toEqual([]) }) + + it('rejects a MARK_IN and shuttle reverse swap that derives the JOIN_ITEMS chord', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }) + + expect(result.bindings.MARK_IN).toBe('i') + expect(result.bindings.SHUTTLE_REVERSE).toBe('j') + expect(result.bindings.JOIN_ITEMS).toBe('shift+j') + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: 'MARK_IN', + binding: 'shift+j', + conflictingCommand: 'JOIN_ITEMS', + resolution: 'fallback', + }), + expect.objectContaining({ + command: 'SHUTTLE_REVERSE', + binding: 'i', + conflictingCommand: 'MARK_IN', + resolution: 'fallback', + }), + ]), + ) + }) + + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'q', + SHUTTLE_REVERSE: 'g', + }) + + expect(result.overrides).toEqual({ MARK_IN: 'q', SHUTTLE_REVERSE: 'g' }) + expect(result.warnings).toEqual([]) + }) }) describe('sanitizeHotkeyOverrides', () => { diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index ce91fa8de..eec17a8f6 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -156,6 +156,14 @@ export interface HotkeyResolution { warnings: HotkeyConflictWarning[] } +type RuntimeHotkeyVariant = 'primary' | 'preview' + +interface RuntimeHotkeyClaim { + command: HotkeyKey + binding: string + variant: RuntimeHotkeyVariant +} + export interface BrowserHostileHotkey { binding: string browserAction: string @@ -432,7 +440,7 @@ export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResol // the participating custom binding(s) back to their unique canonical defaults. // Re-run because one fallback can expose a collision with another custom value. while (true) { - const conflicts = getDuplicateHotkeyCommandGroups(bindings) + const conflicts = getDuplicateRuntimeHotkeyGroups(bindings) if (conflicts.length === 0) break const passWarnings = createConflictFallbackWarnings(conflicts, requested, rejectedOverrides) @@ -460,26 +468,30 @@ function createResolvedHotkeyBindings( ) as HotkeyBindingMap } -function getDuplicateHotkeyCommandGroups(bindings: HotkeyBindingMap): HotkeyKey[][] { - return Object.values(getHotkeyConflictMap(bindings)).filter((commands) => commands.length > 1) +function getDuplicateRuntimeHotkeyGroups(bindings: HotkeyBindingMap): RuntimeHotkeyClaim[][] { + return Object.values(getRuntimeHotkeyConflictGraph(bindings)).filter( + (claims) => new Set(claims.map((claim) => claim.command)).size > 1, + ) } function createConflictFallbackWarnings( - conflicts: HotkeyKey[][], + conflicts: RuntimeHotkeyClaim[][], requested: HotkeyOverrideMap, rejected: Set, ): HotkeyConflictWarning[] { - return conflicts.flatMap((commands) => - commands + return conflicts.flatMap((claims) => { + const commands = [...new Set(claims.map((claim) => claim.command))] + const collisionBinding = claims[0]!.binding + return commands .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) .map((key) => ({ code: 'duplicate_binding' as const, command: key, - binding: normalizeHotkeyBinding(requested[key]!), + binding: collisionBinding, resolution: 'fallback' as const, conflictingCommand: commands.find((command) => command !== key)!, - })), - ) + })) + }) } function getEffectiveHotkeyOverrides(bindings: HotkeyBindingMap): HotkeyOverrideMap { @@ -750,22 +762,63 @@ export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): strin return normalizeHotkeyBinding(tokens.join('+')) } -function getHotkeyConflictMap(bindings: HotkeyBindingMap): Record { - const conflicts: Record = {} +function addShiftModifier(binding: string): string { + const tokens = splitHotkeyBinding(binding) + if (tokens.includes('shift')) return normalizeHotkeyBinding(binding) + const key = tokens.pop() + if (!key) return '' + return normalizeHotkeyBinding([...tokens, 'shift', key].join('+')) +} - for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { - const normalizedBinding = normalizeHotkeyBinding(binding) - if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue +function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): RuntimeHotkeyClaim[] { + const normalizedBinding = normalizeHotkeyBinding(binding) + if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) return [] + + const claims: RuntimeHotkeyClaim[] = [{ command, binding: normalizedBinding, variant: 'primary' }] + if (command === 'MARK_IN' || command === 'MARK_OUT') { + const previewBinding = addShiftModifier(normalizedBinding) + if (previewBinding) { + claims.push({ command, binding: previewBinding, variant: 'preview' }) } + } + return claims +} - conflicts[normalizedBinding] ??= [] - conflicts[normalizedBinding].push(key) +/** + * Canonical graph of every physical chord registered at runtime, including + * modifier-derived variants. Claim insertion order is the deterministic owner + * order when defensive runtime claiming sees an unresolved collision. + */ +function getRuntimeHotkeyConflictGraph( + bindings: HotkeyBindingMap, +): Record { + const conflicts: Record = {} + + for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { + const bindingClaims = conflicts[claim.binding] ?? [] + bindingClaims.push(claim) + conflicts[claim.binding] = bindingClaims + } } return conflicts } +export function getRuntimeHotkeyBinding( + bindings: HotkeyBindingMap, + command: HotkeyKey, + variant: RuntimeHotkeyVariant = 'primary', +): string | null { + const claim = getCommandRuntimeHotkeyClaims(command, bindings[command]).find( + (candidate) => candidate.variant === variant, + ) + if (!claim) return null + + const owner = getRuntimeHotkeyConflictGraph(bindings)[claim.binding]?.[0] + return owner?.command === command && owner.variant === variant ? claim.binding : null +} + export function findHotkeyConflicts( bindings: HotkeyBindingMap, binding: string, @@ -776,9 +829,25 @@ export function findHotkeyConflicts( return [] } - return (getHotkeyConflictMap(bindings)[normalizedBinding] ?? []).filter( - (key) => key !== currentKey, - ) + if (!currentKey) { + return [ + ...new Set( + (getRuntimeHotkeyConflictGraph(bindings)[normalizedBinding] ?? []).map( + (claim) => claim.command, + ), + ), + ] + } + + const candidateBindings = { ...bindings, [currentKey]: normalizedBinding } + const graph = getRuntimeHotkeyConflictGraph(candidateBindings) + const conflicts = new Set() + for (const claim of getCommandRuntimeHotkeyClaims(currentKey, normalizedBinding)) { + for (const candidate of graph[claim.binding] ?? []) { + if (candidate.command !== currentKey) conflicts.add(candidate.command) + } + } + return (Object.keys(HOTKEYS) as HotkeyKey[]).filter((key) => conflicts.has(key)) } export function createHotkeyExportDocument( @@ -989,11 +1058,43 @@ export function parseHotkeyImportDocument(source: unknown): HotkeyImportResult { const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' const DIALOG_SELECTOR = '[role="dialog"], dialog' -const FORM_CONTROL_SELECTOR = 'input, textarea, select' +const INTERACTIVE_CONTROL_SELECTOR = [ + 'button', + 'a[href]', + 'summary', + 'input', + 'textarea', + 'select', + 'option', + 'audio[controls]', + 'video[controls]', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="treeitem"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="textbox"]', + '[role="searchbox"]', + '[role="combobox"]', + '[role="listbox"]', +].join(', ') function isContentEditableTarget(target: Element): boolean { - const editable = target.closest('[contenteditable]') - return editable !== null && editable.getAttribute('contenteditable') !== 'false' + for (let current: Element | null = target; current; current = current.parentElement) { + if (!current.hasAttribute('contenteditable')) continue + const value = current.getAttribute('contenteditable')?.trim().toLowerCase() ?? '' + if (value === 'false') return false + if (value === '' || value === 'true' || value === 'plaintext-only') return true + } + return false } /** @@ -1006,7 +1107,7 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean { if (typeof Element === 'undefined' || !(target instanceof Element)) return false if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false if (isContentEditableTarget(target)) return true - if (target.closest(FORM_CONTROL_SELECTOR)) return true + if (target.closest(INTERACTIVE_CONTROL_SELECTOR)) return true return target.closest(DIALOG_SELECTOR) !== null } diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 2e104e1ef..7af4ab4e8 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -59,6 +59,16 @@ function createShortcutHost(initial: HostShortcutSettings) { } } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe('host shortcut settings round trip', () => { beforeEach(() => { useSettingsStore.getState().resetHotkeys() @@ -222,6 +232,97 @@ describe('host shortcut settings round trip', () => { unmount() }) + it('reconciles newer subscribed state after an older write finishes last', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const firstWrite = createDeferred() + host.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) + firstWrite.resolve() + + await waitFor(() => + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' }), + ), + ) + expect(host.setSettings).toHaveBeenCalledTimes(2) + unmount() + }) + + it('retries the newest subscribed state after an older write rejects', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const firstWrite = createDeferred() + host.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' })) + firstWrite.reject(new Error('old write failed')) + + await waitFor(() => + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'w' }), + ), + ) + expect(host.notify).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'error', message: expect.stringContaining('save') }), + ) + unmount() + }) + + it('fences in-flight host A work when host B replaces it', async () => { + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) + const firstWrite = createDeferred() + hostA.setSettings.mockReturnValueOnce(firstWrite.promise) + const unmountA = await mountHostShortcutSettings(hostA.host) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(hostA.setSettings).toHaveBeenCalledTimes(1)) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host) + expect(hostA.listenerCount()).toBe(0) + expect(hostB.listenerCount()).toBe(1) + + hostA.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'z' })) + firstWrite.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(hostA.setSettings).toHaveBeenCalledTimes(1) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'b' }) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'y') + await waitFor(() => expect(hostB.setSettings).toHaveBeenCalledTimes(1)) + + unmountA() + expect(hostB.listenerCount()).toBe(1) + unmountB() + expect(hostB.listenerCount()).toBe(0) + }) + + it('suppresses equal subscription echoes without a redundant write loop', async () => { + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + const write = createDeferred() + host.setSettings.mockReturnValueOnce(write.promise) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await waitFor(() => expect(host.setSettings).toHaveBeenCalledTimes(1)) + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' })) + write.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(host.setSettings).toHaveBeenCalledTimes(1) + expect(host.listenerCount()).toBe(1) + unmount() + expect(host.listenerCount()).toBe(0) + }) + it('removes the host subscriber on unmount', async () => { const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) const unmount = await mountHostShortcutSettings(host.host) @@ -252,4 +353,24 @@ describe('host shortcut settings round trip', () => { unmount() }) + + it('retains the last valid settings and reports derived host conflict metadata', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+space' }) + expect(harness.notify).toHaveBeenCalledWith({ + kind: 'conflict', + message: expect.stringMatching(/shift\+j.*MARK_IN.*JOIN_ITEMS.*last valid/i), + }) + expect(harness.setSettings).not.toHaveBeenCalled() + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index bb5e99f80..d34ba86a3 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -34,6 +34,7 @@ function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { interface ShortcutOwnership { epoch: number standaloneOverrides: HotkeyOverrideMap + dispose?: () => void } let nextOwnershipEpoch = 0 @@ -47,17 +48,18 @@ export async function mountHostShortcutSettings( host: EditorHost, signal?: AbortSignal, ): Promise<() => void> { + const previousOwnership = currentOwnership + const standaloneOverrides = copyOverrides( + previousOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, + ) + previousOwnership?.dispose?.() const ownership: ShortcutOwnership = { epoch: ++nextOwnershipEpoch, - standaloneOverrides: copyOverrides( - currentOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, - ), + standaloneOverrides, } currentOwnership = ownership let applyingHostSettings = false let disposed = false - let inboundRevision = 0 - let writeQueue = Promise.resolve() let unsubscribeHost: (() => void) | undefined let unsubscribeStore: (() => void) | undefined @@ -66,7 +68,6 @@ export async function mountHostShortcutSettings( const dispose = () => { if (disposed) return disposed = true - inboundRevision += 1 unsubscribeStore?.() unsubscribeHost?.() signal?.removeEventListener('abort', dispose) @@ -74,6 +75,7 @@ export async function mountHostShortcutSettings( currentOwnership = null useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) } + ownership.dispose = dispose if (signal?.aborted) { dispose() @@ -95,20 +97,75 @@ export async function mountHostShortcutSettings( host.notify?.({ kind: 'error', message }) } + const settingsEqual = (left: HostShortcutSettings, right: HostShortcutSettings) => { + const leftKeys = Object.keys(left.overrides) + const rightKeys = Object.keys(right.overrides) + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + left.overrides[key as keyof HotkeyOverrideMap] === + right.overrides[key as keyof HotkeyOverrideMap], + ) + ) + } + + let desiredSettings: HostShortcutSettings | null = null + let settledSettings: HostShortcutSettings | null = null + let inFlightSettings: HostShortcutSettings | null = null + let reconcileAfterFlight = false + let reconcileScheduled = false + + const canStartReconcile = () => { + if (!isCurrent()) return false + if (inFlightSettings || !desiredSettings) return false + if (reconcileAfterFlight || !settledSettings) return true + return !settingsEqual(desiredSettings, settledSettings) + } + + const finishReconcile = (settingsToWrite: HostShortcutSettings, succeeded: boolean) => { + if (!isCurrent()) return + if (succeeded) settledSettings = settingsToWrite + const desiredChanged = + desiredSettings !== null && !settingsEqual(desiredSettings, settingsToWrite) + inFlightSettings = null + if (desiredChanged || reconcileAfterFlight) scheduleReconcile() + } + + const persistDesiredSettings = async () => { + reconcileScheduled = false + if (!canStartReconcile()) return + + const settingsToWrite = desiredSettings! + inFlightSettings = settingsToWrite + reconcileAfterFlight = false + let succeeded = false + try { + await Promise.resolve(port.setSettings(settingsToWrite)) + succeeded = true + } catch { + if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') + } + finishReconcile(settingsToWrite, succeeded) + } + + function scheduleReconcile() { + if (reconcileScheduled || inFlightSettings || !desiredSettings) return + reconcileScheduled = true + void Promise.resolve().then(persistDesiredSettings) + } + const applyHostSettings = (settings: HostShortcutSettings) => { if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) - inboundRevision += 1 if (normalized.warnings.length > 0) { for (const warning of normalized.warnings) { host.notify?.({ kind: 'conflict', - message: - warning.resolution === 'fallback' - ? `Shortcut conflict for ${warning.command}; using its default binding.` - : `Shortcut conflict for ${warning.command}; the binding was disabled.`, + message: `Shortcut ${warning.binding} for ${warning.command} conflicts with ${warning.conflictingCommand}; retained the last valid shortcut settings.`, }) } + return } applyingHostSettings = true try { @@ -116,6 +173,14 @@ export async function mountHostShortcutSettings( } finally { applyingHostSettings = false } + desiredSettings = normalized.settings + if (inFlightSettings) { + reconcileAfterFlight = !settingsEqual(inFlightSettings, normalized.settings) + } else { + // A subscription is the host's persisted authority unless an older write + // can still complete after it and overwrite that state. + settledSettings = normalized.settings + } } let initialSettings: HostShortcutSettings @@ -128,6 +193,10 @@ export async function mountHostShortcutSettings( if (!isCurrent()) { return dispose } + desiredSettings = createHostShortcutSettings( + copyOverrides(useSettingsStore.getState().hotkeyOverrides), + ) + settledSettings = initialSettings applyHostSettings(initialSettings) unsubscribeHost = port.subscribe?.((settings) => { @@ -149,15 +218,8 @@ export async function mountHostShortcutSettings( } const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) - const revisionAtQueue = inboundRevision - writeQueue = writeQueue - .then(() => { - if (!isCurrent() || inboundRevision !== revisionAtQueue) return undefined - return Promise.resolve(port.setSettings(settings)) - }) - .catch(() => { - if (isCurrent()) reportFailure('Could not save keyboard shortcuts to the host.') - }) + desiredSettings = settings + scheduleReconcile() }) return dispose diff --git a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx index bb3124215..0ca0603e5 100644 --- a/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx +++ b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx @@ -36,6 +36,15 @@ function getButton(name: string): HTMLButtonElement { return button as HTMLButtonElement } +function getButtonContaining(text: string): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ) + + expect(button).toBeTruthy() + return button as HTMLButtonElement +} + async function waitForText(text: string): Promise { for (let attempt = 0; attempt < 10; attempt += 1) { const element = [...document.querySelectorAll('body *')].find( @@ -145,6 +154,25 @@ describe('HotkeyEditor reset all confirmation', () => { }) }) + it('displays and rejects a conflict caused by the derived Shift preview chord', async () => { + const searchInput = document.querySelector( + 'input[placeholder="Search commands or shortcuts"]', + ) as HTMLInputElement | null + expect(searchInput).toBeTruthy() + changeInput(searchInput!, 'mark in') + await waitForText('1 result') + click(getButtonContaining('Mark In point')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + click(getButton('Record')) + await waitForText('Listening') + keyDown('j', 'KeyJ') + + await waitForBodyText('Conflicts with Join selected clips') + expect(getButton('Save').disabled).toBe(true) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+k' }) + }) + it('keeps unbind explicit and disables it once the selected command is unassigned', async () => { click(getButton('Unbind')) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/features/timeline/components/timeline-item/trim-handles.test.tsx b/src/features/timeline/components/timeline-item/trim-handles.test.tsx index 4ab108638..a30618976 100644 --- a/src/features/timeline/components/timeline-item/trim-handles.test.tsx +++ b/src/features/timeline/components/timeline-item/trim-handles.test.tsx @@ -1,5 +1,6 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useSettingsStore } from '@/features/timeline/deps/settings' import { TrimHandles } from './trim-handles' import { VideoFadeHandles } from './video-fade-handles' import { AudioFadeHandles } from './audio-fade-handles' @@ -27,6 +28,20 @@ describe('TrimHandles', () => { onJoinRight: vi.fn(), } + const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, 'platform') + + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(window.navigator, 'platform', originalPlatform) + } else { + delete (window.navigator as { platform?: string }).platform + } + }) + it('fires onTrimStart on mousedown when the left handle is visible', () => { const onTrimStart = vi.fn() render() @@ -52,6 +67,34 @@ describe('TrimHandles', () => { fireEvent.mouseDown(rightHandle!) expect(onTrimStart).toHaveBeenCalledWith(expect.any(Object), 'end') }) + + it('updates the trim join menu from the live Windows shortcut binding', async () => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, value: 'Win32' }) + const { container } = render( + , + ) + const leftHandle = container.querySelector('[class*="left-0"]') + expect(leftHandle).toBeTruthy() + fireEvent.contextMenu(leftHandle!) + expect(await screen.findByText('Shift + J')).toBeInTheDocument() + + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+alt+j') + + await waitFor(() => expect(screen.getByText('Ctrl + Alt + J')).toBeInTheDocument()) + }) + + it('formats a remapped trim join shortcut for macOS', async () => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, value: 'MacIntel' }) + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+alt+j') + const { container } = render( + , + ) + const rightHandle = container.querySelector('[class*="right-0"]') + expect(rightHandle).toBeTruthy() + fireEvent.contextMenu(rightHandle!) + + expect(await screen.findByText('Cmd + Option + J')).toBeInTheDocument() + }) }) /** diff --git a/src/features/timeline/components/timeline-item/trim-handles.tsx b/src/features/timeline/components/timeline-item/trim-handles.tsx index 0fc43bd31..f1a01749a 100644 --- a/src/features/timeline/components/timeline-item/trim-handles.tsx +++ b/src/features/timeline/components/timeline-item/trim-handles.tsx @@ -7,6 +7,8 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu' import { cn } from '@/shared/ui/cn' +import { formatHotkeyBinding } from '@/config/hotkeys' +import { useResolvedHotkeys } from '@/features/timeline/deps/settings' import type { SmartTrimIntent } from '../../utils/smart-trim-zones' import { CONSTRAINED_COLORS, @@ -93,6 +95,8 @@ export const TrimHandles = memo(function TrimHandles({ onJoinLeft, onJoinRight, }: TrimHandlesProps) { + const hotkeys = useResolvedHotkeys() + const joinShortcutLabel = formatHotkeyBinding(hotkeys.JOIN_ITEMS) const isRollingStart = smartTrimIntent === 'roll-start' const isRollingEnd = smartTrimIntent === 'roll-end' const isNeighborRollStart = rollHoverEdge === 'start' @@ -196,7 +200,7 @@ export const TrimHandles = memo(function TrimHandles({ Join - J + {joinShortcutLabel} @@ -258,7 +262,7 @@ export const TrimHandles = memo(function TrimHandles({ Join - J + {joinShortcutLabel} diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx new file mode 100644 index 000000000..68221275f --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useHotkeys } from 'react-hotkeys-hook' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { usePlaybackStore } from '@/shared/state/playback' +import { useTimelineStore } from '../../stores/timeline-store' +import { useInOutShortcuts } from './use-in-out-shortcuts' +import { usePlaybackShortcuts } from './use-playback-shortcuts' + +function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { + const hotkeys = useResolvedHotkeys() + usePlaybackShortcuts({}) + useInOutShortcuts() + useHotkeys(hotkeys.JOIN_ITEMS, onJoin, HOTKEY_OPTIONS, [onJoin, hotkeys.JOIN_ITEMS]) + return null +} + +describe('runtime shortcut ownership', () => { + beforeEach(() => { + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + currentFrame: 48, + previewFrame: 120, + previewItemId: null, + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + useTimelineStore.setState({ inPoint: null, outPoint: null }) + }) + + it('executes only JOIN_ITEMS after rejecting the exact derived-chord swap', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + MARK_IN: 'j', + SHUTTLE_REVERSE: 'i', + }) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({}) + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().inPoint).toBeNull() + expect(usePlaybackStore.getState().isPlaying).toBe(false) + }) + + it('keeps ordinary remaps distinct across capture and bubble handlers', () => { + useSettingsStore.getState().replaceHotkeyOverrides({ + MARK_IN: 'q', + SHUTTLE_REVERSE: 'g', + }) + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'Q', code: 'KeyQ', shiftKey: true }) + expect(useTimelineStore.getState().inPoint).toBe(120) + expect(onJoin).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'g', code: 'KeyG' }) + expect(usePlaybackStore.getState()).toMatchObject({ + isPlaying: true, + playbackRate: -1, + transportMode: 'shuttle', + }) + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) + expect(onJoin).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 7c1c75e0e..172b92eb6 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -3,30 +3,15 @@ */ import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useResolvedHotkeys } from '@/features/timeline/deps/settings' -function addShiftModifier(binding: string): string { - const parts = binding - .split('+') - .map((part) => part.trim()) - .filter(Boolean) - - if (parts.some((part) => part.toLowerCase() === 'shift')) { - return binding - } - - const key = parts.pop() - if (!key) return `shift+${binding}` - return [...parts, 'shift', key].join('+') -} - export function useInOutShortcuts() { const hotkeys = useResolvedHotkeys() - const markInAtPreview = addShiftModifier(hotkeys.MARK_IN) - const markOutAtPreview = addShiftModifier(hotkeys.MARK_OUT) + const markInAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_IN', 'preview') + const markOutAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_OUT', 'preview') useHotkeys( hotkeys.MARK_IN, @@ -40,7 +25,7 @@ export function useInOutShortcuts() { ) useHotkeys( - markInAtPreview, + markInAtPreview ?? [], (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() @@ -62,7 +47,7 @@ export function useInOutShortcuts() { ) useHotkeys( - markOutAtPreview, + markOutAtPreview ?? [], (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() From 0e711268ccce8f91500846e57b0f1e8d79e594dd Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 03:20:46 -0700 Subject: [PATCH 12/21] fix(shortcuts): retry host writes and alias collisions --- src/config/hotkeys.test.ts | 84 ++++++++- src/config/hotkeys.ts | 111 ++++++++---- .../editor/host/shortcut-settings.test.ts | 171 +++++++++++++++++- src/features/editor/host/shortcut-settings.ts | 67 ++++++- .../settings/stores/settings-store.test.ts | 12 +- .../settings/stores/settings-store.ts | 3 + .../shortcuts/runtime-conflicts.test.tsx | 39 +++- .../hooks/shortcuts/use-editing-shortcuts.ts | 7 +- 8 files changed, 437 insertions(+), 57 deletions(-) diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 7af7efecb..1ccf86bfb 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -11,6 +11,7 @@ import { getBrowserHostileHotkey, getHotkeyBindingFromEventData, getHotkeyPrimaryTokenFromEventData, + getRuntimeHotkeyBinding, normalizeHotkeyBinding, parseHotkeyImportDocument, resolveHotkeyConfiguration, @@ -58,7 +59,9 @@ describe('transport and editing defaults', () => { describe('normalizeHotkeyBinding', () => { it('orders modifiers consistently and normalizes aliases', () => { - expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('mod+shift+left') + expect(normalizeHotkeyBinding('Shift+Ctrl+ArrowLeft')).toBe('ctrl+shift+left') + expect(normalizeHotkeyBinding('SHIFT+Command+J')).toBe('meta+shift+j') + expect(normalizeHotkeyBinding('shift+MOD+j')).toBe('mod+shift+j') }) }) @@ -70,6 +73,11 @@ describe('formatHotkeyBinding', () => { it('formats punctuation bindings for windows', () => { expect(formatHotkeyBinding('mod+shift+comma', 'Win32')).toBe('Ctrl + Shift + ,') }) + + it('preserves explicit physical modifier labels', () => { + expect(formatHotkeyBinding('ctrl+meta+k', 'MacIntel')).toBe('Ctrl + Cmd + K') + expect(formatHotkeyBinding('meta+ctrl+k', 'Win32')).toBe('Ctrl + Meta + K') + }) }) describe('getBrowserHostileHotkey', () => { @@ -155,6 +163,12 @@ describe('findHotkeyConflicts', () => { expect(findHotkeyConflicts(bindings, 'j', 'MARK_IN')).toContain('JOIN_ITEMS') }) + + it('finds platform alias overlap in primary and derived chords', () => { + const bindings = resolveHotkeys({ MARK_IN: 'meta+j' }) + + expect(findHotkeyConflicts(bindings, 'mod+shift+j', 'JOIN_ITEMS')).toContain('MARK_IN') + }) }) describe('resolveHotkeyConfiguration', () => { @@ -230,6 +244,62 @@ describe('resolveHotkeyConfiguration', () => { ) }) + it('rejects meta versus mod collisions in derived runtime chords', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + }) + + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: 'MARK_IN', + conflictingCommand: 'JOIN_ITEMS', + resolution: 'fallback', + }), + ]), + ) + }) + + it('rejects ctrl versus mod collisions on Windows and Linux', () => { + const result = resolveHotkeyConfiguration({ + MARK_IN: 'ctrl+j', + JOIN_ITEMS: 'mod+shift+j', + }) + + expect(result.overrides).toEqual({}) + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: 'MARK_IN', conflictingCommand: 'JOIN_ITEMS' }), + ]), + ) + }) + + it('keeps explicit meta and ctrl chords distinct', () => { + const result = resolveHotkeyConfiguration({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + + expect(result.overrides).toEqual({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + expect(result.warnings).toEqual([]) + }) + + it('deterministically suppresses one legacy runtime alias claimant', () => { + const bindings = { + ...resolveHotkeys(), + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + } + + expect(getRuntimeHotkeyBinding(bindings, 'JOIN_ITEMS')).toBe('mod+shift+j') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() + }) + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { const result = resolveHotkeyConfiguration({ MARK_IN: 'q', @@ -252,7 +322,7 @@ describe('sanitizeHotkeyOverrides', () => { }), ).toEqual({ PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }) }) @@ -279,7 +349,7 @@ describe('createHotkeyExportDocument', () => { expect(exportDocument.version).toBe(HOTKEY_EXPORT_VERSION) expect(exportDocument.overrides).toEqual({ PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }) expect(exportDocument.commands).toContainEqual( expect.objectContaining({ @@ -300,7 +370,7 @@ describe('createHotkeyExportDocument', () => { expect(exportDocument.commands).toContainEqual( expect.objectContaining({ id: 'EXPORT', - binding: 'mod+e', + binding: 'ctrl+e', defaultBinding: 'mod+shift+e', isCustom: true, }), @@ -362,7 +432,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }, importedCommandCount: 2, ignoredCommandCount: 1, @@ -409,7 +479,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: '', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', }, importedCommandCount: 2, ignoredCommandCount: 0, @@ -429,7 +499,7 @@ describe('parseHotkeyImportDocument', () => { ).toEqual({ overrides: { PLAY_PAUSE: 'shift+space', - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }, importedCommandCount: 3, diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index eec17a8f6..67a2f56ea 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -164,6 +164,10 @@ interface RuntimeHotkeyClaim { variant: RuntimeHotkeyVariant } +interface RuntimePhysicalHotkeyClaim extends RuntimeHotkeyClaim { + physicalBinding: string +} + export interface BrowserHostileHotkey { binding: string browserAction: string @@ -174,17 +178,16 @@ interface HotkeyCommandLookup { byDefaultBinding: Map } -const HOTKEY_MODIFIERS = ['mod', 'alt', 'shift'] as const +const HOTKEY_MODIFIERS = ['mod', 'ctrl', 'meta', 'alt', 'shift'] as const const HOTKEY_MODIFIER_SET = new Set(HOTKEY_MODIFIERS) const HOTKEY_MODIFIER_ORDER = new Map( HOTKEY_MODIFIERS.map((token, index) => [token, index]), ) const HOTKEY_TOKEN_ALIASES: Record = { - cmd: 'mod', - command: 'mod', - ctrl: 'mod', - control: 'mod', + cmd: 'meta', + command: 'meta', + control: 'ctrl', option: 'alt', return: 'enter', esc: 'escape', @@ -224,6 +227,23 @@ const HOTKEY_KEY_LABELS: Record = { enter: 'Enter', } +const HOTKEY_MODIFIER_LABELS: Record> = { + mac: { + mod: 'Cmd', + ctrl: 'Ctrl', + meta: 'Cmd', + alt: 'Option', + shift: 'Shift', + }, + windows: { + mod: 'Ctrl', + ctrl: 'Ctrl', + meta: 'Meta', + alt: 'Alt', + shift: 'Shift', + }, +} + const HOTKEY_CODE_TOKEN_MAP: Record = { Space: 'space', Comma: 'comma', @@ -469,9 +489,16 @@ function createResolvedHotkeyBindings( } function getDuplicateRuntimeHotkeyGroups(bindings: HotkeyBindingMap): RuntimeHotkeyClaim[][] { - return Object.values(getRuntimeHotkeyConflictGraph(bindings)).filter( - (claims) => new Set(claims.map((claim) => claim.command)).size > 1, - ) + const duplicateGroups = new Map() + for (const claims of Object.values(getRuntimeHotkeyConflictGraph(bindings))) { + if (new Set(claims.map((claim) => claim.command)).size < 2) continue + const signature = claims + .map((claim) => `${claim.command}:${claim.variant}:${claim.binding}`) + .sort() + .join('|') + if (!duplicateGroups.has(signature)) duplicateGroups.set(signature, claims) + } + return [...duplicateGroups.values()] } function createConflictFallbackWarnings( @@ -481,13 +508,12 @@ function createConflictFallbackWarnings( ): HotkeyConflictWarning[] { return conflicts.flatMap((claims) => { const commands = [...new Set(claims.map((claim) => claim.command))] - const collisionBinding = claims[0]!.binding return commands .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) .map((key) => ({ code: 'duplicate_binding' as const, command: key, - binding: collisionBinding, + binding: claims.find((claim) => claim.command === key)!.binding, resolution: 'fallback' as const, conflictingCommand: commands.find((command) => command !== key)!, })) @@ -662,17 +688,8 @@ export function hasHotkeyPrimaryToken(binding: string): boolean { } function formatHotkeyToken(token: string, platform: HotkeyPlatform): string { - if (token === 'mod') { - return platform === 'mac' ? 'Cmd' : 'Ctrl' - } - - if (token === 'alt') { - return platform === 'mac' ? 'Option' : 'Alt' - } - - if (token === 'shift') { - return 'Shift' - } + const modifierLabel = HOTKEY_MODIFIER_LABELS[platform][token] + if (modifierLabel) return modifierLabel if (HOTKEY_KEY_LABELS[token]) { return HOTKEY_KEY_LABELS[token] @@ -702,7 +719,15 @@ export function getBrowserHostileHotkey(binding: string): BrowserHostileHotkey | return null } - return BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) ?? null + const directMatch = BROWSER_HOSTILE_HOTKEY_MAP.get(normalizedBinding) + if (directMatch) return directMatch + + const portableModifierBinding = normalizeHotkeyBinding( + splitHotkeyBinding(normalizedBinding) + .map((token) => (token === 'ctrl' || token === 'meta' ? 'mod' : token)) + .join('+'), + ) + return BROWSER_HOSTILE_HOTKEY_MAP.get(portableModifierBinding) ?? null } export function getHotkeyPrimaryTokenFromEventData(eventData: HotkeyEventData): string | null { @@ -784,6 +809,17 @@ function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): Run return claims } +function getPhysicalHotkeyBindings(binding: string): string[] { + const tokens = splitHotkeyBinding(binding) + return (['mac', 'windows'] as const).map((platform) => { + const physicalTokens = tokens.map((token) => { + if (token !== 'mod') return token + return platform === 'mac' ? 'meta' : 'ctrl' + }) + return `${platform}:${normalizeHotkeyBinding(physicalTokens.join('+'))}` + }) +} + /** * Canonical graph of every physical chord registered at runtime, including * modifier-derived variants. Claim insertion order is the deterministic owner @@ -791,14 +827,16 @@ function getCommandRuntimeHotkeyClaims(command: HotkeyKey, binding: string): Run */ function getRuntimeHotkeyConflictGraph( bindings: HotkeyBindingMap, -): Record { - const conflicts: Record = {} +): Record { + const conflicts: Record = {} for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { - const bindingClaims = conflicts[claim.binding] ?? [] - bindingClaims.push(claim) - conflicts[claim.binding] = bindingClaims + for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { + const bindingClaims = conflicts[physicalBinding] ?? [] + bindingClaims.push({ ...claim, physicalBinding }) + conflicts[physicalBinding] = bindingClaims + } } } @@ -815,8 +853,12 @@ export function getRuntimeHotkeyBinding( ) if (!claim) return null - const owner = getRuntimeHotkeyConflictGraph(bindings)[claim.binding]?.[0] - return owner?.command === command && owner.variant === variant ? claim.binding : null + const graph = getRuntimeHotkeyConflictGraph(bindings) + const ownsEveryPhysicalBinding = getPhysicalHotkeyBindings(claim.binding).every((binding) => { + const owner = graph[binding]?.[0] + return owner?.command === command && owner.variant === variant + }) + return ownsEveryPhysicalBinding ? claim.binding : null } export function findHotkeyConflicts( @@ -830,10 +872,11 @@ export function findHotkeyConflicts( } if (!currentKey) { + const graph = getRuntimeHotkeyConflictGraph(bindings) return [ ...new Set( - (getRuntimeHotkeyConflictGraph(bindings)[normalizedBinding] ?? []).map( - (claim) => claim.command, + getPhysicalHotkeyBindings(normalizedBinding).flatMap((physicalBinding) => + (graph[physicalBinding] ?? []).map((claim) => claim.command), ), ), ] @@ -843,8 +886,10 @@ export function findHotkeyConflicts( const graph = getRuntimeHotkeyConflictGraph(candidateBindings) const conflicts = new Set() for (const claim of getCommandRuntimeHotkeyClaims(currentKey, normalizedBinding)) { - for (const candidate of graph[claim.binding] ?? []) { - if (candidate.command !== currentKey) conflicts.add(candidate.command) + for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { + for (const candidate of graph[physicalBinding] ?? []) { + if (candidate.command !== currentKey) conflicts.add(candidate.command) + } } } return (Object.keys(HOTKEYS) as HotkeyKey[]).filter((key) => conflicts.has(key)) diff --git a/src/features/editor/host/shortcut-settings.test.ts b/src/features/editor/host/shortcut-settings.test.ts index 7af4ab4e8..0db8de565 100644 --- a/src/features/editor/host/shortcut-settings.test.ts +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { createElement } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, waitFor } from '@testing-library/react' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' @@ -10,7 +10,7 @@ import { useSettingsStore } from '@/features/editor/deps/settings' import { useHostTimelineShortcuts } from '@/features/editor/deps/timeline-hooks' import { usePlaybackStore } from '@/shared/state/playback' import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract' -import { mountHostShortcutSettings } from './shortcut-settings' +import { HOST_SHORTCUT_RETRY_DELAYS_MS, mountHostShortcutSettings } from './shortcut-settings' function HostShortcutHarness() { useHostTimelineShortcuts() @@ -69,6 +69,33 @@ function createDeferred() { return { promise, resolve, reject } } +function createRetryScheduler() { + let nextTimerId = 0 + const timers = new Map void; delayMs: number }>() + return { + scheduler: { + setTimeout: (callback: () => void, delayMs: number) => { + const timerId = ++nextTimerId + timers.set(timerId, { callback, delayMs }) + return timerId + }, + clearTimeout: (timer: unknown) => timers.delete(timer as number), + }, + pendingCount: () => timers.size, + pendingDelays: () => [...timers.values()].map((timer) => timer.delayMs), + runNext: async () => { + const entry = timers.entries().next().value as + | [number, { callback: () => void; delayMs: number }] + | undefined + if (!entry) throw new Error('No retry timer is pending') + timers.delete(entry[0]) + entry[1].callback() + await Promise.resolve() + await Promise.resolve() + }, + } +} + describe('host shortcut settings round trip', () => { beforeEach(() => { useSettingsStore.getState().resetHotkeys() @@ -79,6 +106,10 @@ describe('host shortcut settings round trip', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('hydrates host bindings, persists UI changes, and accepts agent updates', async () => { useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) const harness = createShortcutHost( @@ -275,6 +306,120 @@ describe('host shortcut settings round trip', () => { unmount() }) + it('retries the newest desired settings after their host write rejects', async () => { + vi.useFakeTimers() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(host.setSettings).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(HOST_SHORTCUT_RETRY_DELAYS_MS[0]) + + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' }), + ) + unmount() + }) + + it('backs repeated failures with one capped timer and no tight loop', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValue(new Error('persistent failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(host.setSettings).toHaveBeenCalledTimes(1) + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[0]]) + + await retry.runNext() + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[1]]) + + for (let retryIndex = 2; retryIndex < HOST_SHORTCUT_RETRY_DELAYS_MS.length; retryIndex += 1) { + await retry.runNext() + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS[retryIndex]]) + } + await retry.runNext() + expect(retry.pendingCount()).toBe(1) + expect(retry.pendingDelays()).toEqual([HOST_SHORTCUT_RETRY_DELAYS_MS.at(-1)!]) + expect(host.setSettings).toHaveBeenCalledTimes(HOST_SHORTCUT_RETRY_DELAYS_MS.length + 1) + unmount() + expect(retry.pendingCount()).toBe(0) + }) + + it('persists only the newest desired settings after a change during backoff', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') + await Promise.resolve() + await Promise.resolve() + + expect(host.setSettings).toHaveBeenCalledTimes(2) + expect(host.setSettings).toHaveBeenLastCalledWith( + createHostShortcutSettings({ SHUTTLE_PAUSE: 'f10' }), + ) + expect(retry.pendingCount()).toBe(0) + unmount() + }) + + it('cancels a pending retry when equal inbound settings acknowledge the desired value', async () => { + const retry = createRetryScheduler() + const host = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'p' })) + host.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmount = await mountHostShortcutSettings(host.host, undefined, retry.scheduler) + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + host.emit(createHostShortcutSettings({ SHUTTLE_PAUSE: 'x' })) + expect(retry.pendingCount()).toBe(0) + expect(host.setSettings).toHaveBeenCalledTimes(1) + unmount() + }) + + it('cancels a disposed host retry and fences it from the replacement host', async () => { + const retry = createRetryScheduler() + const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) + hostA.setSettings.mockRejectedValueOnce(new Error('transient failure')) + const unmountA = await mountHostShortcutSettings(hostA.host, undefined, retry.scheduler) + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'x') + await Promise.resolve() + await Promise.resolve() + expect(retry.pendingCount()).toBe(1) + + const hostB = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'b' })) + const unmountB = await mountHostShortcutSettings(hostB.host, undefined, retry.scheduler) + expect(retry.pendingCount()).toBe(0) + expect(hostA.setSettings).toHaveBeenCalledTimes(1) + expect(hostB.setSettings).not.toHaveBeenCalled() + + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') + await Promise.resolve() + expect(hostB.setSettings).toHaveBeenCalledTimes(1) + unmountA() + unmountB() + }) + it('fences in-flight host A work when host B replaces it', async () => { const hostA = createShortcutHost(createHostShortcutSettings({ SHUTTLE_PAUSE: 'a' })) const firstWrite = createDeferred() @@ -295,7 +440,7 @@ describe('host shortcut settings round trip', () => { expect(hostA.setSettings).toHaveBeenCalledTimes(1) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'b' }) - useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'y') + useSettingsStore.getState().setHotkeyBinding('SHUTTLE_PAUSE', 'f10') await waitFor(() => expect(hostB.setSettings).toHaveBeenCalledTimes(1)) unmountA() @@ -373,4 +518,24 @@ describe('host shortcut settings round trip', () => { expect(harness.setSettings).not.toHaveBeenCalled() unmount() }) + + it('retains the last valid settings and reports meta versus mod host conflicts', async () => { + useSettingsStore.getState().replaceHotkeyOverrides({ PLAY_PAUSE: 'shift+space' }) + const harness = createShortcutHost( + createHostShortcutSettings({ + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + }), + ) + + const unmount = await mountHostShortcutSettings(harness.host) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ PLAY_PAUSE: 'shift+space' }) + expect(harness.notify).toHaveBeenCalledWith({ + kind: 'conflict', + message: expect.stringMatching(/MARK_IN.*JOIN_ITEMS.*last valid/i), + }) + expect(harness.setSettings).not.toHaveBeenCalled() + unmount() + }) }) diff --git a/src/features/editor/host/shortcut-settings.ts b/src/features/editor/host/shortcut-settings.ts index d34ba86a3..703a6fed6 100644 --- a/src/features/editor/host/shortcut-settings.ts +++ b/src/features/editor/host/shortcut-settings.ts @@ -12,6 +12,18 @@ import { type HostShortcutSettings, } from './contract' +export const HOST_SHORTCUT_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const + +export interface HostShortcutRetryScheduler { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(timer: unknown): void +} + +const DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER: HostShortcutRetryScheduler = { + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), +} + function normalizeHostShortcutSettings(settings: HostShortcutSettings): { settings: HostShortcutSettings warnings: HotkeyConflictWarning[] @@ -47,6 +59,7 @@ let currentOwnership: ShortcutOwnership | null = null export async function mountHostShortcutSettings( host: EditorHost, signal?: AbortSignal, + retryScheduler: HostShortcutRetryScheduler = DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER, ): Promise<() => void> { const previousOwnership = currentOwnership const standaloneOverrides = copyOverrides( @@ -62,12 +75,15 @@ export async function mountHostShortcutSettings( let disposed = false let unsubscribeHost: (() => void) | undefined let unsubscribeStore: (() => void) | undefined + let retryTimer: unknown const isCurrent = () => !disposed && currentOwnership?.epoch === ownership.epoch const dispose = () => { if (disposed) return disposed = true + if (retryTimer !== undefined) retryScheduler.clearTimeout(retryTimer) + retryTimer = undefined unsubscribeStore?.() unsubscribeHost?.() signal?.removeEventListener('abort', dispose) @@ -115,6 +131,13 @@ export async function mountHostShortcutSettings( let inFlightSettings: HostShortcutSettings | null = null let reconcileAfterFlight = false let reconcileScheduled = false + let retryAttempt = 0 + + const cancelRetry = (resetAttempt = false) => { + if (retryTimer !== undefined) retryScheduler.clearTimeout(retryTimer) + retryTimer = undefined + if (resetAttempt) retryAttempt = 0 + } const canStartReconcile = () => { if (!isCurrent()) return false @@ -123,13 +146,28 @@ export async function mountHostShortcutSettings( return !settingsEqual(desiredSettings, settledSettings) } + const desiredDiffersFrom = (settings: HostShortcutSettings) => + desiredSettings !== null && !settingsEqual(desiredSettings, settings) + + const hasUnsettledDesiredSettings = () => + desiredSettings !== null && + (settledSettings === null || !settingsEqual(desiredSettings, settledSettings)) + const finishReconcile = (settingsToWrite: HostShortcutSettings, succeeded: boolean) => { if (!isCurrent()) return - if (succeeded) settledSettings = settingsToWrite - const desiredChanged = - desiredSettings !== null && !settingsEqual(desiredSettings, settingsToWrite) + if (succeeded) { + settledSettings = settingsToWrite + retryAttempt = 0 + } + const desiredChanged = desiredDiffersFrom(settingsToWrite) inFlightSettings = null - if (desiredChanged || reconcileAfterFlight) scheduleReconcile() + if (desiredChanged || reconcileAfterFlight) { + scheduleReconcile() + return + } + if (!succeeded && hasUnsettledDesiredSettings()) { + scheduleRetry() + } } const persistDesiredSettings = async () => { @@ -155,6 +193,17 @@ export async function mountHostShortcutSettings( void Promise.resolve().then(persistDesiredSettings) } + function scheduleRetry() { + if (retryTimer !== undefined || inFlightSettings || !desiredSettings || !isCurrent()) return + const retryIndex = Math.min(retryAttempt, HOST_SHORTCUT_RETRY_DELAYS_MS.length - 1) + const delay = HOST_SHORTCUT_RETRY_DELAYS_MS[retryIndex]! + retryAttempt += 1 + retryTimer = retryScheduler.setTimeout(() => { + retryTimer = undefined + scheduleReconcile() + }, delay) + } + const applyHostSettings = (settings: HostShortcutSettings) => { if (!isCurrent()) return const normalized = normalizeHostShortcutSettings(settings) @@ -174,12 +223,13 @@ export async function mountHostShortcutSettings( applyingHostSettings = false } desiredSettings = normalized.settings + // A subscription is persisted host authority. It acknowledges an equal + // dirty value and supersedes a differing value unless an older write can + // still finish afterward, in which case that authority is reconciled once. + settledSettings = normalized.settings + cancelRetry(true) if (inFlightSettings) { reconcileAfterFlight = !settingsEqual(inFlightSettings, normalized.settings) - } else { - // A subscription is the host's persisted authority unless an older write - // can still complete after it and overwrite that state. - settledSettings = normalized.settings } } @@ -219,6 +269,7 @@ export async function mountHostShortcutSettings( const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) desiredSettings = settings + cancelRetry(true) scheduleReconcile() }) diff --git a/src/features/settings/stores/settings-store.test.ts b/src/features/settings/stores/settings-store.test.ts index 4406d663e..52cb6fd4c 100644 --- a/src/features/settings/stores/settings-store.test.ts +++ b/src/features/settings/stores/settings-store.test.ts @@ -104,7 +104,7 @@ describe('settings-store', () => { } as never) expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ - EXPORT: 'mod+e', + EXPORT: 'ctrl+e', DELETE_SELECTED: '', }) }) @@ -124,5 +124,15 @@ describe('settings-store', () => { expect(useSettingsStore.getState()).toBe(previousState) }) + + it('retains the last valid UI settings when an alias collision is attempted', () => { + useSettingsStore.getState().setHotkeyBinding('MARK_IN', 'meta+j') + const previousState = useSettingsStore.getState() + + useSettingsStore.getState().setHotkeyBinding('JOIN_ITEMS', 'mod+shift+j') + + expect(useSettingsStore.getState()).toBe(previousState) + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ MARK_IN: 'meta+j' }) + }) }) }) diff --git a/src/features/settings/stores/settings-store.ts b/src/features/settings/stores/settings-store.ts index a4f9d4ea0..a3816f625 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -231,6 +231,9 @@ export const useSettingsStore = create()( ...state.hotkeyOverrides, [key]: normalizedBinding, }) + if (resolution.warnings.length > 0) { + return state + } const nextOverrides = resolution.overrides if (areHotkeyOverridesEqual(state.hotkeyOverrides, nextOverrides)) { diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index 68221275f..a0823b3d4 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -1,23 +1,42 @@ import { fireEvent, render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { + HOTKEY_OPTIONS, + getRuntimeHotkeyBinding, + resolveHotkeys, + type HotkeyBindingMap, +} from '@/config/hotkeys' import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useInOutShortcuts } from './use-in-out-shortcuts' import { usePlaybackShortcuts } from './use-playback-shortcuts' +const runtimeHotkeysOverride = vi.hoisted(() => ({ + current: null as HotkeyBindingMap | null, +})) + +vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), + } +}) + function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { const hotkeys = useResolvedHotkeys() + const joinBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') usePlaybackShortcuts({}) useInOutShortcuts() - useHotkeys(hotkeys.JOIN_ITEMS, onJoin, HOTKEY_OPTIONS, [onJoin, hotkeys.JOIN_ITEMS]) + useHotkeys(joinBinding ?? [], onJoin, HOTKEY_OPTIONS, [onJoin, joinBinding]) return null } describe('runtime shortcut ownership', () => { beforeEach(() => { + runtimeHotkeysOverride.current = null useSettingsStore.getState().resetHotkeys() usePlaybackStore.setState({ currentFrame: 48, @@ -68,4 +87,20 @@ describe('runtime shortcut ownership', () => { fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', shiftKey: true }) expect(onJoin).toHaveBeenCalledTimes(1) }) + + it('executes one deterministic handler for a legacy meta versus mod collision', () => { + const legacyHotkeys = { + ...resolveHotkeys(), + MARK_IN: 'meta+j', + JOIN_ITEMS: 'mod+shift+j', + } + runtimeHotkeysOverride.current = legacyHotkeys + const onJoin = vi.fn() + render() + + fireEvent.keyDown(document, { key: 'J', code: 'KeyJ', metaKey: true, shiftKey: true }) + + expect(onJoin).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().inPoint).toBeNull() + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 276c038c8..1ada257ae 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -8,7 +8,7 @@ import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' -import { HOTKEY_OPTIONS } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { canJoinMultipleItems } from '@/features/timeline/utils/clip-utils' import { canLinkSelection, hasLinkedItems } from '@/features/timeline/utils/linked-items' import { @@ -26,6 +26,7 @@ import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { const hotkeys = useResolvedHotkeys() + const joinItemsBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -206,7 +207,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useHotkeys( - hotkeys.JOIN_ITEMS, + joinItemsBinding ?? [], (event) => { if (selectedItemIds.length < 2) return @@ -222,7 +223,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { } }, HOTKEY_OPTIONS, - [selectedItemIds, items, joinItems], + [joinItemsBinding, selectedItemIds, items, joinItems], ) useHotkeys( From 93d57a2d9805a3cb50bb7d1cf3d9ba01729d6f36 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 04:14:07 -0700 Subject: [PATCH 13/21] fix global runtime hotkey ownership --- src/config/hotkeys.test.ts | 46 ++++++ src/config/hotkeys.ts | 63 +++++++- ...ntime-hotkey-registration-coverage.test.ts | 50 ++++++ src/features/editor/deps/settings-contract.ts | 5 +- .../editor/hooks/use-editor-hotkeys.ts | 4 +- .../components/source-monitor.test.tsx | 31 ++++ .../preview/components/source-monitor.tsx | 17 +- .../preview/deps/settings-contract.ts | 5 +- .../settings/hooks/use-resolved-hotkeys.ts | 9 +- .../components/keyframe-graph-panel.tsx | 4 +- .../timeline/deps/settings-contract.ts | 5 +- .../shortcuts/runtime-conflicts.test.tsx | 147 ++++++++++++++++++ .../shortcuts/use-clipboard-shortcuts.ts | 4 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 4 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 11 +- .../hooks/shortcuts/use-in-out-shortcuts.ts | 9 +- .../hooks/shortcuts/use-marker-shortcuts.ts | 4 +- .../hooks/shortcuts/use-playback-shortcuts.ts | 4 +- .../shortcuts/use-source-monitor-shortcuts.ts | 4 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 4 +- .../hooks/shortcuts/use-ui-shortcuts.ts | 4 +- 21 files changed, 389 insertions(+), 45 deletions(-) create mode 100644 src/config/runtime-hotkey-registration-coverage.test.ts diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 1ccf86bfb..0b2b7fe3c 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -6,6 +6,7 @@ import { HOTKEY_EXPORT_SCHEMA, HOTKEY_EXPORT_VERSION, createHotkeyExportDocument, + doesHotkeyEventMatchBinding, findHotkeyConflicts, formatHotkeyBinding, getBrowserHostileHotkey, @@ -16,6 +17,7 @@ import { parseHotkeyImportDocument, resolveHotkeyConfiguration, resolveHotkeys, + resolveRuntimeHotkeys, sanitizeHotkeyOverrides, } from './hotkeys' @@ -149,6 +151,19 @@ describe('getHotkeyBindingFromEventData', () => { }) }) +describe('doesHotkeyEventMatchBinding', () => { + const f10 = { key: 'F10', code: 'F10' } + + it('distinguishes explicit meta and ctrl while keeping mod portable', () => { + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'meta+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'ctrl+f10')).toBe(false) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'ctrl+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'meta+f10')).toBe(false) + expect(doesHotkeyEventMatchBinding({ ...f10, metaKey: true }, 'mod+f10')).toBe(true) + expect(doesHotkeyEventMatchBinding({ ...f10, ctrlKey: true }, 'mod+f10')).toBe(true) + }) +}) + describe('findHotkeyConflicts', () => { it('returns other bindings using the same normalized shortcut', () => { const bindings = resolveHotkeys({ @@ -300,6 +315,37 @@ describe('resolveHotkeyConfiguration', () => { expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() }) + it('uses declaration order even when a legacy binding map has a different key order', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } + const reordered = Object.fromEntries(Object.entries(bindings).toReversed()) as typeof bindings + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + }) + expect(resolveRuntimeHotkeys(reordered)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + }) + }) + + it('keeps distinct explicit meta and ctrl runtime bindings reachable', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'ctrl+f10', + } + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'ctrl+f10', + }) + }) + it('keeps ordinary remaps whose direct and derived runtime chords are unique', () => { const result = resolveHotkeyConfiguration({ MARK_IN: 'q', diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 67a2f56ea..1d8b2d327 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -105,6 +105,8 @@ export type HotkeyBindingMap = Record export type HotkeyOverrideMap = Partial> type HotkeyPlatform = 'mac' | 'windows' +const HOTKEY_COMMAND_ORDER = Object.keys(HOTKEYS) as HotkeyKey[] + export const HOTKEY_EXPORT_SCHEMA = 'freecut-hotkeys' export const HOTKEY_EXPORT_VERSION = 2 @@ -787,6 +789,28 @@ export function getHotkeyBindingFromEventData(eventData: HotkeyEventData): strin return normalizeHotkeyBinding(tokens.join('+')) } +/** Exact runtime matching for local handlers, including explicit meta/ctrl remaps. */ +export function doesHotkeyEventMatchBinding(eventData: HotkeyEventData, binding: string): boolean { + const tokens = splitHotkeyBinding(binding) + const eventKey = eventData.code ?? eventData.key ?? '' + const functionKey = /^F(?:[1-9]|1[0-2])$/i.test(eventKey) ? eventKey.toLowerCase() : null + const primaryToken = getHotkeyPrimaryTokenFromEventData(eventData) ?? functionKey + if (!primaryToken || !tokens.includes(primaryToken)) return false + + const usesMod = tokens.includes('mod') + const expectsCtrl = tokens.includes('ctrl') + const expectsMeta = tokens.includes('meta') + const controlModifierMatches = usesMod + ? Boolean(eventData.ctrlKey || eventData.metaKey) + : Boolean(eventData.ctrlKey) === expectsCtrl && Boolean(eventData.metaKey) === expectsMeta + + return ( + controlModifierMatches && + Boolean(eventData.altKey) === tokens.includes('alt') && + Boolean(eventData.shiftKey) === tokens.includes('shift') + ) +} + function addShiftModifier(binding: string): string { const tokens = splitHotkeyBinding(binding) if (tokens.includes('shift')) return normalizeHotkeyBinding(binding) @@ -822,15 +846,18 @@ function getPhysicalHotkeyBindings(binding: string): string[] { /** * Canonical graph of every physical chord registered at runtime, including - * modifier-derived variants. Claim insertion order is the deterministic owner - * order when defensive runtime claiming sees an unresolved collision. + * modifier-derived variants. Ownership follows HOTKEYS declaration order, + * with each command's primary claim before its derived preview claim. This + * order is independent of persisted/host object insertion order so defensive + * runtime claiming stays stable even for invalid external state. */ function getRuntimeHotkeyConflictGraph( bindings: HotkeyBindingMap, ): Record { const conflicts: Record = {} - for (const [key, binding] of Object.entries(bindings) as [HotkeyKey, string][]) { + for (const key of HOTKEY_COMMAND_ORDER) { + const binding = bindings[key] for (const claim of getCommandRuntimeHotkeyClaims(key, binding)) { for (const physicalBinding of getPhysicalHotkeyBindings(claim.binding)) { const bindingClaims = conflicts[physicalBinding] ?? [] @@ -843,17 +870,17 @@ function getRuntimeHotkeyConflictGraph( return conflicts } -export function getRuntimeHotkeyBinding( +function getOwnedRuntimeHotkeyBinding( + graph: Record, bindings: HotkeyBindingMap, command: HotkeyKey, - variant: RuntimeHotkeyVariant = 'primary', + variant: RuntimeHotkeyVariant, ): string | null { const claim = getCommandRuntimeHotkeyClaims(command, bindings[command]).find( (candidate) => candidate.variant === variant, ) if (!claim) return null - const graph = getRuntimeHotkeyConflictGraph(bindings) const ownsEveryPhysicalBinding = getPhysicalHotkeyBindings(claim.binding).every((binding) => { const owner = graph[binding]?.[0] return owner?.command === command && owner.variant === variant @@ -861,6 +888,30 @@ export function getRuntimeHotkeyBinding( return ownsEveryPhysicalBinding ? claim.binding : null } +/** + * Returns the runtime-only primary registration map. A command that loses any + * canonical physical alias is disabled with an empty binding; raw resolved and + * persisted settings are never mutated. + */ +export function resolveRuntimeHotkeys(bindings: HotkeyBindingMap): HotkeyBindingMap { + const graph = getRuntimeHotkeyConflictGraph(bindings) + return Object.fromEntries( + HOTKEY_COMMAND_ORDER.map((command) => [ + command, + getOwnedRuntimeHotkeyBinding(graph, bindings, command, 'primary') ?? '', + ]), + ) as HotkeyBindingMap +} + +export function getRuntimeHotkeyBinding( + bindings: HotkeyBindingMap, + command: HotkeyKey, + variant: RuntimeHotkeyVariant = 'primary', +): string | null { + const graph = getRuntimeHotkeyConflictGraph(bindings) + return getOwnedRuntimeHotkeyBinding(graph, bindings, command, variant) +} + export function findHotkeyConflicts( bindings: HotkeyBindingMap, binding: string, diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts new file mode 100644 index 000000000..c7a1e165f --- /dev/null +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node + +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vite-plus/test' + +const SRC_ROOT = join(process.cwd(), 'src') + +function productionSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return productionSourceFiles(path) + if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] + return [path] + }) +} + +describe('runtime hotkey registration coverage', () => { + it('routes every direct command-map useHotkeys registration through the runtime map', () => { + const directRegistrationFiles = productionSourceFiles(SRC_ROOT).filter((path) => { + const source = readFileSync(path, 'utf8') + return /useHotkeys\(\s*hotkeys\.[A-Z0-9_]+/.test(source) + }) + + expect(directRegistrationFiles.length).toBeGreaterThan(0) + for (const path of directRegistrationFiles) { + expect(readFileSync(path, 'utf8'), relative(process.cwd(), path)).toContain( + 'useRuntimeHotkeys', + ) + } + }) + + it('feeds derived keyframe registrations and local source-monitor matching from the runtime map', () => { + const keyframePanel = readFileSync( + join(SRC_ROOT, 'features/timeline/components/keyframe-graph-panel.tsx'), + 'utf8', + ) + const sourceMonitor = readFileSync( + join(SRC_ROOT, 'features/preview/components/source-monitor.tsx'), + 'utf8', + ) + + expect(keyframePanel).toContain('useRuntimeHotkeys') + expect(keyframePanel).toMatch(/shortcuts=\{\{[\s\S]*hotkeys\.EDIT_KEYFRAME_ADD/) + expect(sourceMonitor).toContain('useRuntimeHotkeys') + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_IN\)/) + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_OUT\)/) + expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.CLEAR_IN_OUT\)/) + }) +}) diff --git a/src/features/editor/deps/settings-contract.ts b/src/features/editor/deps/settings-contract.ts index 49a43a6bb..8ab606a1d 100644 --- a/src/features/editor/deps/settings-contract.ts +++ b/src/features/editor/deps/settings-contract.ts @@ -12,5 +12,8 @@ export { export type { CaptioningIntervalUnit } from '@/features/settings/stores/settings-store' export { LocalInferenceUnloadControl } from '@/features/settings/components/local-inference-unload-control' export { LocalModelCacheControl } from '@/features/settings/components/local-model-cache-control' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' export { HotkeyEditor } from '@/features/settings/components/hotkey-editor' diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index aeb6b5ad7..2ae48439d 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,6 +1,6 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/editor/deps/settings' +import { useRuntimeHotkeys } from '@/features/editor/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser' @@ -24,7 +24,7 @@ interface EditorHotkeyCallbacks { * Uses react-hotkeys-hook with granular Zustand selectors */ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const enableLocalUi = callbacks.enableLocalUi ?? true // Save: Cmd/Ctrl+S diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index ce41d0bba..64624f93e 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -78,6 +78,10 @@ const resolvedHotkeysState = vi.hoisted(() => ({ }, })) +const runtimeHotkeysState = vi.hoisted(() => ({ + hotkeys: { ...resolvedHotkeysState.hotkeys }, +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, @@ -151,6 +155,7 @@ vi.mock('@/features/preview/deps/settings', () => { return { useSettingsStore, useResolvedHotkeys: () => resolvedHotkeysState.hotkeys, + useRuntimeHotkeys: () => runtimeHotkeysState.hotkeys, } }) @@ -225,6 +230,7 @@ describe('SourceMonitor current media ownership', () => { INSERT_EDIT: 'comma', OVERWRITE_EDIT: 'period', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } }) it('updates visible shortcut labels after remap and reset', async () => { @@ -236,6 +242,7 @@ describe('SourceMonitor current media ownership', () => { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } rendered.rerender() expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument() @@ -247,11 +254,35 @@ describe('SourceMonitor current media ownership', () => { expect(rendered.getByLabelText('Mark In (I)')).toBeInTheDocument() }) + it('keeps the raw local label while a losing runtime binding is disabled', async () => { + resolvedHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: 'meta+f10', + } + runtimeHotkeysState.hotkeys = { + ...resolvedHotkeysState.hotkeys, + MARK_IN: '', + } + sourcePlayerStoreState.currentSourceFrame = 42 + const rendered = render() + await waitFor(() => expect(rendered.getByLabelText(/Mark In \(.+f10\)/i)).toBeInTheDocument()) + + fireEvent.keyDown(rendered.container.firstElementChild!, { + key: 'F10', + code: 'F10', + metaKey: true, + }) + + expect(sourcePlayerStoreState.setInPoint).not.toHaveBeenCalled() + expect(resolvedHotkeysState.hotkeys.MARK_IN).toBe('meta+f10') + }) + it('uses the same reactive binding for local source-monitor actions', async () => { resolvedHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys, MARK_IN: 'shift+f', } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } sourcePlayerStoreState.currentSourceFrame = 42 const rendered = render() await waitFor(() => expect(rendered.getByLabelText('Mark In (Shift + F)')).toBeInTheDocument()) diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index 0479fcd9d..aa2c4abb1 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -70,8 +70,8 @@ import { import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' -import { formatHotkeyBinding, getHotkeyBindingFromEventData } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/preview/deps/settings' +import { doesHotkeyEventMatchBinding, formatHotkeyBinding } from '@/config/hotkeys' +import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/preview/deps/settings' interface SourceMonitorProps { mediaId: string @@ -208,6 +208,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) const hotkeys = useResolvedHotkeys() + const runtimeHotkeys = useRuntimeHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -280,6 +281,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ seekFrame={seekFrame} onClose={onClose} hotkeys={hotkeys} + runtimeHotkeys={runtimeHotkeys} /> @@ -304,6 +306,7 @@ interface SourceMonitorInnerProps { seekFrame: number | null onClose?: () => void hotkeys: ReturnType + runtimeHotkeys: ReturnType } function SourceMonitorInner({ @@ -321,6 +324,7 @@ function SourceMonitorInner({ seekFrame, onClose, hotkeys, + runtimeHotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -451,24 +455,23 @@ function SourceMonitorInner({ (e: React.KeyboardEvent) => { if (!interactive) return if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - const binding = getHotkeyBindingFromEventData(e) const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (binding === hotkeys.MARK_IN) { + if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_IN)) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (binding === hotkeys.MARK_OUT) { + } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_OUT)) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (binding === hotkeys.CLEAR_IN_OUT) { + } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.CLEAR_IN_OUT)) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, hotkeys, interactive], + [durationInFrames, interactive, runtimeHotkeys], ) const handleMouseEnter = useCallback(() => { diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index 7f300ac99..f99f7c7d9 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,4 +4,7 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/settings/hooks/use-resolved-hotkeys.ts b/src/features/settings/hooks/use-resolved-hotkeys.ts index aef1cdacc..9d91a1555 100644 --- a/src/features/settings/hooks/use-resolved-hotkeys.ts +++ b/src/features/settings/hooks/use-resolved-hotkeys.ts @@ -1,7 +1,14 @@ import { useShallow } from 'zustand/react/shallow' -import { resolveHotkeys } from '@/config/hotkeys' +import { resolveHotkeys, resolveRuntimeHotkeys } from '@/config/hotkeys' import { useSettingsStore } from '../stores/settings-store' export function useResolvedHotkeys() { return useSettingsStore(useShallow((state) => resolveHotkeys(state.hotkeyOverrides))) } + +/** Runtime registrations only; display and persistence must use useResolvedHotkeys. */ +export function useRuntimeHotkeys() { + return useSettingsStore( + useShallow((state) => resolveRuntimeHotkeys(resolveHotkeys(state.hotkeyOverrides))), + ) +} diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index 6b2ecb6d7..bb055db31 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -96,7 +96,7 @@ import { updateTextMotionLive, } from '../stores/actions/text-motion-actions' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { getDirectPropertyLinks, isTransformAnimatableProperty } from '@/types/keyframe' import { buildEffectPropertyResetPlan } from '@/features/timeline/utils/effect-property-reset' import { VectorSpeedGraph } from './vector-speed-graph' @@ -1228,7 +1228,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ })), [t], ) - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() // Ref to measure container width const containerRef = useRef(null) const panelRef = useRef(null) diff --git a/src/features/timeline/deps/settings-contract.ts b/src/features/timeline/deps/settings-contract.ts index 6c7f53151..e01af00db 100644 --- a/src/features/timeline/deps/settings-contract.ts +++ b/src/features/timeline/deps/settings-contract.ts @@ -4,4 +4,7 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' +export { + useResolvedHotkeys, + useRuntimeHotkeys, +} from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index a0823b3d4..81d0c4955 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -3,13 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, + HOTKEYS, getRuntimeHotkeyBinding, resolveHotkeys, type HotkeyBindingMap, + type HotkeyKey, } from '@/config/hotkeys' import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' import { usePlaybackStore } from '@/shared/state/playback' +import { useSelectionStore } from '@/shared/state/selection' import { useTimelineStore } from '../../stores/timeline-store' +import type { TimelineTrack, VideoItem } from '@/types/timeline' +import { useEditingShortcuts } from './use-editing-shortcuts' import { useInOutShortcuts } from './use-in-out-shortcuts' import { usePlaybackShortcuts } from './use-playback-shortcuts' @@ -17,11 +22,28 @@ const runtimeHotkeysOverride = vi.hoisted(() => ({ current: null as HotkeyBindingMap | null, })) +function runtimePrimaryBindings(bindings: HotkeyBindingMap): HotkeyBindingMap { + return Object.fromEntries( + (Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [ + command, + getRuntimeHotkeyBinding(bindings, command) ?? '', + ]), + ) as HotkeyBindingMap +} + +const originalPlaybackActions = { + togglePlayPause: usePlaybackStore.getState().togglePlayPause, + shuttleForward: usePlaybackStore.getState().shuttleForward, + shuttleReverse: usePlaybackStore.getState().shuttleReverse, +} + vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), + useRuntimeHotkeys: () => + runtimePrimaryBindings(runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys()), } }) @@ -34,6 +56,36 @@ function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { return null } +function FullRuntimeConflictHarness() { + usePlaybackShortcuts({}) + useEditingShortcuts({}) + useInOutShortcuts() + return null +} + +const TRACK: TimelineTrack = { + id: 'track-1', + name: 'V1', + kind: 'video', + order: 0, + height: 80, + locked: false, + visible: true, + muted: false, + solo: false, + items: [], +} + +const ITEM: VideoItem = { + id: 'clip-1', + type: 'video', + trackId: TRACK.id, + from: 0, + durationInFrames: 100, + label: 'Clip 1', + src: 'clip.mp4', +} + describe('runtime shortcut ownership', () => { beforeEach(() => { runtimeHotkeysOverride.current = null @@ -45,8 +97,10 @@ describe('runtime shortcut ownership', () => { isPlaying: false, playbackRate: 1, transportMode: 'normal', + ...originalPlaybackActions, }) useTimelineStore.setState({ inPoint: null, outPoint: null }) + useSelectionStore.setState({ selectedItemIds: [] }) }) it('executes only JOIN_ITEMS after rejecting the exact derived-chord swap', () => { @@ -103,4 +157,97 @@ describe('runtime shortcut ownership', () => { expect(onJoin).toHaveBeenCalledTimes(1) expect(useTimelineStore.getState().inPoint).toBeNull() }) + + it('gives PLAY_PAUSE sole ownership of a legacy meta versus mod transport collision', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F10', code: 'F10', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + }) + + it('gives playback sole ownership across playback and split shortcut hooks', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + SHUTTLE_FORWARD: 'mod+f9', + SPLIT_AT_PLAYHEAD_ALT: 'meta+f9', + } + usePlaybackStore.setState({ currentFrame: 50 }) + useTimelineStore.setState({ tracks: [TRACK], items: [ITEM] }) + const shuttleForward = vi.fn(originalPlaybackActions.shuttleForward) + usePlaybackStore.setState({ shuttleForward }) + render() + + fireEvent.keyDown(document, { key: 'F9', code: 'F9', metaKey: true }) + + expect(shuttleForward).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState().items).toEqual([ITEM]) + }) + + it('keeps physically distinct explicit meta and ctrl bindings reachable', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f8', + SHUTTLE_REVERSE: 'ctrl+f8', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F8', code: 'F8', metaKey: true }) + fireEvent.keyDown(document, { key: 'F8', code: 'F8', ctrlKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).toHaveBeenCalledTimes(1) + }) + + it('does not let a bubble registration duplicate a capture-owned event', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f7', + CLEAR_IN_OUT: 'mod+f7', + } + useTimelineStore.setState({ inPoint: 10, outPoint: 20 }) + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + usePlaybackStore.setState({ togglePlayPause }) + render() + + fireEvent.keyDown(document, { key: 'F7', code: 'F7', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(useTimelineStore.getState()).toMatchObject({ inPoint: 10, outPoint: 20 }) + }) + + it('filters only runtime ownership without rewriting raw bindings or labels', () => { + const persistedOverrides = { + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + } as const + const displayHotkeys = { ...resolveHotkeys(), ...persistedOverrides } + runtimeHotkeysOverride.current = displayHotkeys + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + usePlaybackStore.setState({ togglePlayPause, shuttleReverse }) + render() + + fireEvent.keyDown(document, { key: 'F10', code: 'F10', metaKey: true }) + + expect(togglePlayPause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + expect(persistedOverrides).toEqual({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + }) + expect(displayHotkeys).toMatchObject(persistedOverrides) + }) }) diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index d0f05df13..d8cb476b2 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -15,7 +15,7 @@ import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { Transition } from '@/types/transition' import type { TimelineItem } from '@/types/timeline' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, @@ -64,7 +64,7 @@ function revealPastedItems(itemIds: readonly string[]): void { } export function useClipboardShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) const selectedKeyframes = useKeyframeSelectionStore((s) => s.selectedKeyframes) diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index fa4d0ddfa..b67f5ad95 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -14,11 +14,11 @@ import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedMarkerId = useSelectionStore((s) => s.selectedMarkerId) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 1ada257ae..4ea024200 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -8,7 +8,7 @@ import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' -import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { canJoinMultipleItems } from '@/features/timeline/utils/clip-utils' import { canLinkSelection, hasLinkedItems } from '@/features/timeline/utils/linked-items' import { @@ -20,13 +20,12 @@ import { import type { TransformProperties } from '@/types/transform' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useClearKeyframesDialogStore } from '@/shared/state/clear-keyframes-dialog' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() - const joinItemsBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') + const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -207,7 +206,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useHotkeys( - joinItemsBinding ?? [], + hotkeys.JOIN_ITEMS, (event) => { if (selectedItemIds.length < 2) return @@ -223,7 +222,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { } }, HOTKEY_OPTIONS, - [joinItemsBinding, selectedItemIds, items, joinItems], + [selectedItemIds, items, joinItems], ) useHotkeys( diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 172b92eb6..4d57e68d5 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -6,12 +6,13 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useInOutShortcuts() { - const hotkeys = useResolvedHotkeys() - const markInAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_IN', 'preview') - const markOutAtPreview = getRuntimeHotkeyBinding(hotkeys, 'MARK_OUT', 'preview') + const resolvedHotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() + const markInAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_IN', 'preview') + const markOutAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_OUT', 'preview') useHotkeys( hotkeys.MARK_IN, diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index cd6881792..fd1339501 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -8,10 +8,10 @@ import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { addMarker, removeMarker } from '../../stores/actions/marker-actions' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useMarkerShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame) const clearSelection = useSelectionStore((s) => s.clearSelection) diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index 6892b51ff..1f25d07fd 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -14,7 +14,7 @@ import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getFilteredItemSnapEdges } from '../../utils/timeline-snap-utils' import { getVisibleTrackIds } from '../../utils/group-utils' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' /** Compute snap points on-demand from current store state (avoids reactive subscriptions). */ function getSnapPoints(): number[] { @@ -35,7 +35,7 @@ function getSnapPoints(): number[] { } export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) const shuttleForward = usePlaybackStore((s) => s.shuttleForward) const shuttleReverse = usePlaybackStore((s) => s.shuttleReverse) diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 0cabacc2c..3564ceae1 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -13,10 +13,10 @@ import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useSourceMonitorShortcuts() { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() // Insert Edit: , (comma) — works globally when source monitor is open useHotkeys( diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 27170fa2e..f49883515 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -8,11 +8,11 @@ import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { SLIP_SLIDE_TOOLS_ENABLED } from '../../constants' export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const activeTool = useSelectionStore((s) => s.activeTool) const setActiveTool = useSelectionStore((s) => s.setActiveTool) diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index cffd9cbc7..3d4f9f14b 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -8,7 +8,7 @@ import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { useRuntimeHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' export interface UIShortcutOptions { /** @@ -23,7 +23,7 @@ export function useUIShortcuts( options: UIShortcutOptions = {}, ) { const { enableHistory = true } = options - const hotkeys = useResolvedHotkeys() + const hotkeys = useRuntimeHotkeys() const toggleSnap = useTimelineStore((s) => s.toggleSnap) const zoomIn = useZoomStore((s) => s.zoomIn) const zoomOut = useZoomStore((s) => s.zoomOut) From 8388082972bb0c1f4f8b75cc2c9f84d407543f0e Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 04:53:26 -0700 Subject: [PATCH 14/21] Fix atomic runtime hotkey ownership --- package-lock.json | 1 + package.json | 1 + scripts/runtime-hotkey-import-boundary.d.mts | 17 ++++ scripts/runtime-hotkey-import-boundary.mjs | 95 +++++++++++++++++++ src/config/hotkeys.test.ts | 35 +++++++ src/config/hotkeys.ts | 29 +++++- ...ntime-hotkey-registration-coverage.test.ts | 59 +++++++----- src/features/editor/deps/settings-contract.ts | 5 +- .../editor/hooks/use-editor-hotkeys.ts | 16 ++-- .../components/dopesheet-editor/index.tsx | 91 +++++++----------- .../dopesheet-editor/shortcuts.test.tsx | 35 ------- .../components/source-monitor.test.tsx | 5 + .../preview/components/source-monitor.tsx | 18 ++-- .../preview/deps/settings-contract.ts | 5 +- .../settings/hooks/use-resolved-hotkeys.ts | 9 +- .../components/keyframe-graph-panel.tsx | 23 ++--- .../timeline/deps/settings-contract.ts | 5 +- .../shortcuts/runtime-conflicts.test.tsx | 54 ++++++++--- .../shortcuts/use-clipboard-shortcuts.ts | 10 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 8 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 38 ++++---- .../hooks/shortcuts/use-in-out-shortcuts.ts | 34 +++---- .../hooks/shortcuts/use-marker-shortcuts.ts | 12 +-- .../hooks/shortcuts/use-playback-shortcuts.ts | 24 +++-- .../shortcuts/use-source-monitor-shortcuts.ts | 9 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 18 ++-- .../hooks/shortcuts/use-ui-shortcuts.ts | 23 +++-- src/hooks/use-hotkey-registration.ts | 68 +++++++++++++ src/hooks/use-runtime-hotkey-binding.ts | 46 +++++++++ 29 files changed, 511 insertions(+), 282 deletions(-) create mode 100644 scripts/runtime-hotkey-import-boundary.d.mts create mode 100644 scripts/runtime-hotkey-import-boundary.mjs create mode 100644 src/hooks/use-hotkey-registration.ts create mode 100644 src/hooks/use-runtime-hotkey-binding.ts diff --git a/package-lock.json b/package-lock.json index f32878f6d..d928ec33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "zustand": "5.0.12" }, "devDependencies": { + "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/package.json b/package.json index 1f84b65e2..db2a46ab0 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "zustand": "5.0.12" }, "devDependencies": { + "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/scripts/runtime-hotkey-import-boundary.d.mts b/scripts/runtime-hotkey-import-boundary.d.mts new file mode 100644 index 000000000..d7006a2c9 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.d.mts @@ -0,0 +1,17 @@ +export interface RuntimeHotkeyBoundarySource { + path: string + source: string +} + +export interface RuntimeHotkeyImportViolation { + path: string + line: number + column: number +} + +export declare const RUNTIME_HOTKEY_ADAPTER_PATH: 'src/hooks/use-hotkey-registration.ts' + +export declare function findReactHotkeysHookImportViolations( + sources: RuntimeHotkeyBoundarySource[], + allowedPath?: string, +): RuntimeHotkeyImportViolation[] diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs new file mode 100644 index 000000000..a5e5f8583 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -0,0 +1,95 @@ +import { parse } from '@babel/parser' + +const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' +export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' + +function isReactHotkeysSource(source) { + return source?.type === 'StringLiteral' && source.value === REACT_HOTKEYS_HOOK_MODULE +} + +function isStaticReactHotkeysImport(node) { + const hasStaticSource = + node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration' + return hasStaticSource && isReactHotkeysSource(node.source) +} + +function isTypeScriptReactHotkeysImport(node) { + if (node.type !== 'TSImportEqualsDeclaration') return false + const reference = node.moduleReference + return ( + reference.type === 'TSExternalModuleReference' && isReactHotkeysSource(reference.expression) + ) +} + +function isReactHotkeysCallImport(node) { + if (node.type !== 'CallExpression') return false + const { callee, arguments: args } = node + const isRequire = callee.type === 'Identifier' && callee.name === 'require' + return ( + (isRequire || callee.type === 'Import') && args.length === 1 && isReactHotkeysSource(args[0]) + ) +} + +function isReactHotkeysImportExpression(node) { + return node.type === 'ImportExpression' && isReactHotkeysSource(node.source) +} + +const IMPORT_NODE_CHECKS = [ + isStaticReactHotkeysImport, + isTypeScriptReactHotkeysImport, + isReactHotkeysCallImport, + isReactHotkeysImportExpression, +] +const AST_METADATA_KEYS = new Set(['loc', 'start', 'end']) + +function walkAst(root, onNode) { + const pending = [root] + while (pending.length > 0) { + const node = pending.pop() + if (!node || typeof node !== 'object') continue + if (Array.isArray(node)) { + pending.push(...node) + continue + } + + onNode(node) + for (const [key, child] of Object.entries(node)) { + if (!AST_METADATA_KEYS.has(key)) pending.push(child) + } + } +} + +export function findReactHotkeysHookImportViolations( + sources, + allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, +) { + const violations = [] + + for (const { path, source } of sources) { + const ast = parse(source, { + sourceType: 'unambiguous', + plugins: ['typescript', 'jsx', 'dynamicImport'], + }) + + function record(node) { + if (path !== allowedPath) { + violations.push({ + path, + line: node.loc?.start.line ?? 1, + column: (node.loc?.start.column ?? 0) + 1, + }) + } + } + + walkAst(ast, (node) => { + if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + }) + } + + return violations.sort( + (left, right) => + left.path.localeCompare(right.path) || left.line - right.line || left.column - right.column, + ) +} diff --git a/src/config/hotkeys.test.ts b/src/config/hotkeys.test.ts index 0b2b7fe3c..63a67638b 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -333,6 +333,41 @@ describe('resolveHotkeyConfiguration', () => { }) }) + it('does not let a dead portable claimant reserve an uncollided platform alias', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + } + const reordered = Object.fromEntries(Object.entries(bindings).toReversed()) as typeof bindings + + expect(resolveRuntimeHotkeys(bindings)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + expect(resolveRuntimeHotkeys(reordered)).toMatchObject({ + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: '', + SHUTTLE_PAUSE: 'ctrl+f10', + }) + }) + + it('does not let a dead derived claimant reserve an uncollided platform alias', () => { + const bindings = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+shift+f10', + MARK_IN: 'mod+f10', + INSERT_EDIT: 'ctrl+shift+f10', + } + + expect(getRuntimeHotkeyBinding(bindings, 'PLAY_PAUSE')).toBe('meta+shift+f10') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN')).toBe('mod+f10') + expect(getRuntimeHotkeyBinding(bindings, 'MARK_IN', 'preview')).toBeNull() + expect(getRuntimeHotkeyBinding(bindings, 'INSERT_EDIT')).toBe('ctrl+shift+f10') + }) + it('keeps distinct explicit meta and ctrl runtime bindings reachable', () => { const bindings = { ...resolveHotkeys(), diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index 1d8b2d327..976db12ef 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -870,6 +870,31 @@ function getRuntimeHotkeyConflictGraph( return conflicts } +/** + * Runtime ownership graph containing only live candidates. Each primary or + * derived candidate acquires every platform alias as one transaction. A + * collision with any earlier live candidate rejects the whole candidate and + * leaves every one of its aliases available to later declarations. + */ +function getOwnedRuntimeHotkeyConflictGraph( + bindings: HotkeyBindingMap, +): Record { + const owned: Record = {} + + for (const command of HOTKEY_COMMAND_ORDER) { + for (const claim of getCommandRuntimeHotkeyClaims(command, bindings[command])) { + const physicalBindings = getPhysicalHotkeyBindings(claim.binding) + if (physicalBindings.some((physicalBinding) => owned[physicalBinding]?.length)) continue + + for (const physicalBinding of physicalBindings) { + owned[physicalBinding] = [{ ...claim, physicalBinding }] + } + } + } + + return owned +} + function getOwnedRuntimeHotkeyBinding( graph: Record, bindings: HotkeyBindingMap, @@ -894,7 +919,7 @@ function getOwnedRuntimeHotkeyBinding( * persisted settings are never mutated. */ export function resolveRuntimeHotkeys(bindings: HotkeyBindingMap): HotkeyBindingMap { - const graph = getRuntimeHotkeyConflictGraph(bindings) + const graph = getOwnedRuntimeHotkeyConflictGraph(bindings) return Object.fromEntries( HOTKEY_COMMAND_ORDER.map((command) => [ command, @@ -908,7 +933,7 @@ export function getRuntimeHotkeyBinding( command: HotkeyKey, variant: RuntimeHotkeyVariant = 'primary', ): string | null { - const graph = getRuntimeHotkeyConflictGraph(bindings) + const graph = getOwnedRuntimeHotkeyConflictGraph(bindings) return getOwnedRuntimeHotkeyBinding(graph, bindings, command, variant) } diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index c7a1e165f..e3c3d5542 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -3,6 +3,10 @@ import { readdirSync, readFileSync } from 'node:fs' import { join, relative } from 'node:path' import { describe, expect, it } from 'vite-plus/test' +import { + RUNTIME_HOTKEY_ADAPTER_PATH, + findReactHotkeysHookImportViolations, +} from '../../scripts/runtime-hotkey-import-boundary.mjs' const SRC_ROOT = join(process.cwd(), 'src') @@ -16,35 +20,38 @@ function productionSourceFiles(directory: string): string[] { } describe('runtime hotkey registration coverage', () => { - it('routes every direct command-map useHotkeys registration through the runtime map', () => { - const directRegistrationFiles = productionSourceFiles(SRC_ROOT).filter((path) => { - const source = readFileSync(path, 'utf8') - return /useHotkeys\(\s*hotkeys\.[A-Z0-9_]+/.test(source) - }) + it('allows react-hotkeys-hook only in the production registration adapter', () => { + const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ + path: relative(process.cwd(), path), + source: readFileSync(path, 'utf8'), + })) - expect(directRegistrationFiles.length).toBeGreaterThan(0) - for (const path of directRegistrationFiles) { - expect(readFileSync(path, 'utf8'), relative(process.cwd(), path)).toContain( - 'useRuntimeHotkeys', - ) - } + expect(findReactHotkeysHookImportViolations(sources)).toEqual([]) }) - it('feeds derived keyframe registrations and local source-monitor matching from the runtime map', () => { - const keyframePanel = readFileSync( - join(SRC_ROOT, 'features/timeline/components/keyframe-graph-panel.tsx'), - 'utf8', - ) - const sourceMonitor = readFileSync( - join(SRC_ROOT, 'features/preview/components/source-monitor.tsx'), - 'utf8', - ) + it.each([ + ['aliased static import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], + ['default import', "import hotkeyHooks from 'react-hotkeys-hook'"], + ['namespace import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], + ['destructured require', "const { useHotkeys } = require('react-hotkeys-hook')"], + ['TypeScript import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], + ['wrapper re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], + ['dynamic import', "const hooks = await import('react-hotkeys-hook')"], + ])('rejects a %s bypass', (_label, source) => { + expect( + findReactHotkeysHookImportViolations([{ path: 'src/features/bypass.ts', source }]), + ).toEqual([expect.objectContaining({ path: 'src/features/bypass.ts', line: 1 })]) + }) - expect(keyframePanel).toContain('useRuntimeHotkeys') - expect(keyframePanel).toMatch(/shortcuts=\{\{[\s\S]*hotkeys\.EDIT_KEYFRAME_ADD/) - expect(sourceMonitor).toContain('useRuntimeHotkeys') - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_IN\)/) - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.MARK_OUT\)/) - expect(sourceMonitor).toMatch(/doesHotkeyEventMatchBinding\(e, runtimeHotkeys\.CLEAR_IN_OUT\)/) + it('allows the exact adapter module and no similarly named wrapper', () => { + const source = "import { useHotkeys } from 'react-hotkeys-hook'" + expect( + findReactHotkeysHookImportViolations([{ path: RUNTIME_HOTKEY_ADAPTER_PATH, source }]), + ).toEqual([]) + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/hooks/use-hotkey-registration-wrapper.ts', source }, + ]), + ).toHaveLength(1) }) }) diff --git a/src/features/editor/deps/settings-contract.ts b/src/features/editor/deps/settings-contract.ts index 8ab606a1d..49a43a6bb 100644 --- a/src/features/editor/deps/settings-contract.ts +++ b/src/features/editor/deps/settings-contract.ts @@ -12,8 +12,5 @@ export { export type { CaptioningIntervalUnit } from '@/features/settings/stores/settings-store' export { LocalInferenceUnloadControl } from '@/features/settings/components/local-inference-unload-control' export { LocalModelCacheControl } from '@/features/settings/components/local-model-cache-control' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' export { HotkeyEditor } from '@/features/settings/components/hotkey-editor' diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index 2ae48439d..6b70a11d8 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,6 +1,5 @@ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useRuntimeHotkeys } from '@/features/editor/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser' @@ -24,11 +23,10 @@ interface EditorHotkeyCallbacks { * Uses react-hotkeys-hook with granular Zustand selectors */ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { - const hotkeys = useRuntimeHotkeys() const enableLocalUi = callbacks.enableLocalUi ?? true // Save: Cmd/Ctrl+S - useHotkeys( + useCommandHotkey( hotkeys.SAVE, (event) => { event.preventDefault() @@ -41,7 +39,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) // Export: Cmd/Ctrl+Shift+E - useHotkeys( + useCommandHotkey( hotkeys.EXPORT, (event) => { event.preventDefault() @@ -56,7 +54,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Open Scene Browser: Cmd/Ctrl+Shift+F — capture phase because the // default browser binding is a no-op here but Chrome will still eat it // if our listener is in bubbling phase. - useHotkeys( + useCommandHotkey( hotkeys.OPEN_SCENE_BROWSER, (event) => { if (!enableLocalUi) return @@ -69,7 +67,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Workspace switching: Alt+1 (Edit), Alt+2 (Color), Alt+3 (Motion). // WORKSPACE_ANIMATE retains its persisted command id for shortcut migration. - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_EDIT, (event) => { if (!enableLocalUi) return @@ -80,7 +78,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_COLOR, (event) => { if (!enableLocalUi) return @@ -91,7 +89,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( + useCommandHotkey( hotkeys.WORKSPACE_ANIMATE, (event) => { if (!enableLocalUi) return diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx index 57b347873..6b751e6ae 100644 --- a/src/features/keyframes/components/dopesheet-editor/index.tsx +++ b/src/features/keyframes/components/dopesheet-editor/index.tsx @@ -17,7 +17,11 @@ import { } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { useHotkeys } from 'react-hotkeys-hook' +import { + COMMAND_HOTKEYS as hotkeys, + useCommandHotkey, + useLocalHotkey, +} from '@/hooks/use-hotkey-registration' import { ChevronDown, ChevronLeft, @@ -477,14 +481,6 @@ interface DopesheetEditorProps { shortcutsEnabled?: boolean /** Keep the Edit add-keyframe shortcut active while its dock is open. */ addKeyframeShortcutEnabled?: boolean - /** User-configurable bindings for high-frequency keyframe actions. */ - shortcuts?: { - addKeyframe: string - previousKeyframe: string - nextKeyframe: string - toggleAutoKey: string - fitKeyframes: string - } /** Additional class name */ className?: string } @@ -913,7 +909,6 @@ export const DopesheetEditor = memo(function DopesheetEditor({ showPlayhead = true, shortcutsEnabled = false, addKeyframeShortcutEnabled = false, - shortcuts, className, }: DopesheetEditorProps) { perfMarkRender('DopesheetEditor') @@ -1583,13 +1578,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ linkedTimelineViewportWidth !== undefined && linkedTimelineViewportWidth > 0 const timelineCellBorderWidth = - presentation === 'classic' - ? hasLinkedTimelineAxis - ? 0 - : 1 - : presentation === 'lanes' - ? 1 - : 0 + presentation === 'classic' ? (hasLinkedTimelineAxis ? 0 : 1) : presentation === 'lanes' ? 1 : 0 const effectiveTimelineWidth = Math.max( hasLinkedTimelineAxis ? linkedTimelineViewportWidth @@ -1692,12 +1681,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, [affectedFrameRange, effectiveTimelineWidth, frameToX]) const sharedGridFrameToX = useCallback( (frame: number) => - getFrameAxisX( - frame, - viewport, - effectiveTimelineWidth + timelineCellBorderWidth, - 0, - ) - timelineCellBorderWidth, + getFrameAxisX(frame, viewport, effectiveTimelineWidth + timelineCellBorderWidth, 0) - + timelineCellBorderWidth, [effectiveTimelineWidth, timelineCellBorderWidth, viewport], ) const getRenderedKeyframeX = useCallback( @@ -1947,8 +1932,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ if (timelineGridDivisions && timelineGridDivisions > 0) { return Array.from( { length: timelineGridDivisions + 1 }, - (_, index) => - viewport.startFrame + (index / timelineGridDivisions) * frameRange, + (_, index) => viewport.startFrame + (index / timelineGridDivisions) * frameRange, ) } const step = getNiceTickStep(frameRange) @@ -2557,8 +2541,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ? propertyRowByProperty.get(selectedProperty) : undefined - useHotkeys( - shortcuts?.addKeyframe ?? '', + useCommandHotkey( + hotkeys.EDIT_KEYFRAME_ADD, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2571,9 +2555,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ { ...HOTKEY_OPTIONS, enabled: - (shortcutsEnabled || addKeyframeShortcutEnabled) && - !disabled && - Boolean(shortcuts?.addKeyframe && activePropertyRow), + (shortcutsEnabled || addKeyframeShortcutEnabled) && !disabled && Boolean(activePropertyRow), }, [ activePropertyRow, @@ -2584,8 +2566,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ], ) - useHotkeys( - shortcuts?.previousKeyframe ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_PREVIOUS, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2594,14 +2576,13 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && !disabled && Boolean(shortcuts?.previousKeyframe && activePropertyRow), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow), }, [activePropertyRow, disabled, handleRowNavigate, shortcutsEnabled], ) - useHotkeys( - shortcuts?.nextKeyframe ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_NEXT, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2610,14 +2591,13 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && !disabled && Boolean(shortcuts?.nextKeyframe && activePropertyRow), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow), }, [activePropertyRow, disabled, handleRowNavigate, shortcutsEnabled], ) - useHotkeys( - shortcuts?.toggleAutoKey ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_TOGGLE_AUTO, (event) => { event.preventDefault() if (activePropertyRow) { @@ -2626,29 +2606,26 @@ export const DopesheetEditor = memo(function DopesheetEditor({ }, { ...HOTKEY_OPTIONS, - enabled: - shortcutsEnabled && - !disabled && - Boolean(shortcuts?.toggleAutoKey && activePropertyRow && onPropertyValueCommit), + enabled: shortcutsEnabled && !disabled && Boolean(activePropertyRow && onPropertyValueCommit), }, [activePropertyRow, disabled, handleRowAutoKeyToggle, onPropertyValueCommit, shortcutsEnabled], ) - useHotkeys( - shortcuts?.fitKeyframes ?? '', + useCommandHotkey( + hotkeys.KEYFRAME_FIT, (event) => { event.preventDefault() fitKeyframesInView() }, { ...HOTKEY_OPTIONS, - enabled: shortcutsEnabled && !disabled && Boolean(shortcuts?.fitKeyframes), + enabled: shortcutsEnabled && !disabled, }, [disabled, fitKeyframesInView, shortcutsEnabled], ) - useHotkeys( - 'delete,backspace', + useLocalHotkey( + 'DOPESHEET_DELETE', (event) => { event.preventDefault() if (selectedRefs.length > 0) { @@ -2659,8 +2636,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs, onRemoveKeyframes], ) - useHotkeys( - 'left', + useLocalHotkey( + 'DOPESHEET_NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedKeyframes(-1) @@ -2669,8 +2646,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'right', + useLocalHotkey( + 'DOPESHEET_NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedKeyframes(1) @@ -2679,8 +2656,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'shift+left', + useLocalHotkey( + 'DOPESHEET_NUDGE_LEFT_LARGE', (event) => { event.preventDefault() nudgeSelectedKeyframes(-10) @@ -2689,8 +2666,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'shift+right', + useLocalHotkey( + 'DOPESHEET_NUDGE_RIGHT_LARGE', (event) => { event.preventDefault() nudgeSelectedKeyframes(10) diff --git a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx index 5e8fee251..96528bb66 100644 --- a/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx +++ b/src/features/keyframes/components/dopesheet-editor/shortcuts.test.tsx @@ -25,13 +25,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -57,13 +50,6 @@ describe('DopesheetEditor shortcuts', () => { onAddKeyframe={onAddKeyframe} onRemoveKeyframes={onRemoveKeyframes} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -86,13 +72,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled={false} - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -119,13 +98,6 @@ describe('DopesheetEditor shortcuts', () => { onNavigateToKeyframe={onNavigateToKeyframe} shortcutsEnabled={false} addKeyframeShortcutEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) @@ -153,13 +125,6 @@ describe('DopesheetEditor shortcuts', () => { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled - shortcuts={{ - addKeyframe: 'shift+k', - previousKeyframe: 'alt+bracketleft', - nextKeyframe: 'alt+bracketright', - toggleAutoKey: 'a', - fitKeyframes: 'f', - }} />, ) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index 64624f93e..56863e3f3 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -82,6 +82,11 @@ const runtimeHotkeysState = vi.hoisted(() => ({ hotkeys: { ...resolvedHotkeysState.hotkeys }, })) +vi.mock('@/hooks/use-runtime-hotkey-binding', () => ({ + useRuntimeHotkeyBinding: (command: keyof typeof runtimeHotkeysState.hotkeys) => + runtimeHotkeysState.hotkeys[command] ?? '', +})) + vi.mock('@/features/preview/deps/player-context', () => ({ PlayerEmitterProvider: ({ children }: { children: ReactNode }) => <>{children}, ClockBridgeProvider: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx index aa2c4abb1..ad6cc10ea 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -71,7 +71,8 @@ import { formatTimecodeCompact } from '@/shared/utils/time-utils' import { getPreviewPixelSnapSize } from '../utils/preview-pixel-snap' import type { TimelineTrack } from '@/types/timeline' import { doesHotkeyEventMatchBinding, formatHotkeyBinding } from '@/config/hotkeys' -import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/preview/deps/settings' +import { useCommandHotkeyBinding } from '@/hooks/use-hotkey-registration' +import { useResolvedHotkeys } from '@/features/preview/deps/settings' interface SourceMonitorProps { mediaId: string @@ -208,7 +209,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ const [blobUrl, setBlobUrl] = useState('') const media = useMediaLibraryStore((s) => s.mediaById[mediaId]) const hotkeys = useResolvedHotkeys() - const runtimeHotkeys = useRuntimeHotkeys() // Sync current media ID into source player store for I/O points useEffect(() => { @@ -281,7 +281,6 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ seekFrame={seekFrame} onClose={onClose} hotkeys={hotkeys} - runtimeHotkeys={runtimeHotkeys} /> @@ -306,7 +305,6 @@ interface SourceMonitorInnerProps { seekFrame: number | null onClose?: () => void hotkeys: ReturnType - runtimeHotkeys: ReturnType } function SourceMonitorInner({ @@ -324,7 +322,6 @@ function SourceMonitorInner({ seekFrame, onClose, hotkeys, - runtimeHotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -449,6 +446,9 @@ function SourceMonitorInner({ }, [interactive, setHoveredPanel, setPlayerMethods]) // Handle I/O shortcuts locally on this element (not global useHotkeys) + const markInHotkey = useCommandHotkeyBinding('MARK_IN') + const markOutHotkey = useCommandHotkeyBinding('MARK_OUT') + const clearInOutHotkey = useCommandHotkeyBinding('CLEAR_IN_OUT') const wrapperRef = useRef(null) const hadFocusRef = useRef(false) const handleKeyDown = useCallback( @@ -457,21 +457,21 @@ function SourceMonitorInner({ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_IN)) { + if (doesHotkeyEventMatchBinding(e, markInHotkey)) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.MARK_OUT)) { + } else if (doesHotkeyEventMatchBinding(e, markOutHotkey)) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (doesHotkeyEventMatchBinding(e, runtimeHotkeys.CLEAR_IN_OUT)) { + } else if (doesHotkeyEventMatchBinding(e, clearInOutHotkey)) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, interactive, runtimeHotkeys], + [clearInOutHotkey, durationInFrames, interactive, markInHotkey, markOutHotkey], ) const handleMouseEnter = useCallback(() => { diff --git a/src/features/preview/deps/settings-contract.ts b/src/features/preview/deps/settings-contract.ts index f99f7c7d9..7f300ac99 100644 --- a/src/features/preview/deps/settings-contract.ts +++ b/src/features/preview/deps/settings-contract.ts @@ -4,7 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/settings/hooks/use-resolved-hotkeys.ts b/src/features/settings/hooks/use-resolved-hotkeys.ts index 9d91a1555..aef1cdacc 100644 --- a/src/features/settings/hooks/use-resolved-hotkeys.ts +++ b/src/features/settings/hooks/use-resolved-hotkeys.ts @@ -1,14 +1,7 @@ import { useShallow } from 'zustand/react/shallow' -import { resolveHotkeys, resolveRuntimeHotkeys } from '@/config/hotkeys' +import { resolveHotkeys } from '@/config/hotkeys' import { useSettingsStore } from '../stores/settings-store' export function useResolvedHotkeys() { return useSettingsStore(useShallow((state) => resolveHotkeys(state.hotkeyOverrides))) } - -/** Runtime registrations only; display and persistence must use useResolvedHotkeys. */ -export function useRuntimeHotkeys() { - return useSettingsStore( - useShallow((state) => resolveRuntimeHotkeys(resolveHotkeys(state.hotkeyOverrides))), - ) -} diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index bb055db31..da7f40eb1 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -17,7 +17,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { Maximize2, Minimize2, X } from 'lucide-react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' @@ -96,7 +96,6 @@ import { updateTextMotionLive, } from '../stores/actions/text-motion-actions' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { getDirectPropertyLinks, isTransformAnimatableProperty } from '@/types/keyframe' import { buildEffectPropertyResetPlan } from '@/features/timeline/utils/effect-property-reset' import { VectorSpeedGraph } from './vector-speed-graph' @@ -1228,7 +1227,6 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ })), [t], ) - const hotkeys = useRuntimeHotkeys() // Ref to measure container width const containerRef = useRef(null) const panelRef = useRef(null) @@ -2748,7 +2746,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ // The view-mode toggle is always visible now, so the hotkeys map to it in // every context (including the Animate workspace's split-capable toggle). - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_GRAPH, (event) => { event.preventDefault() @@ -2761,7 +2759,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_DOPESHEET, (event) => { event.preventDefault() @@ -2774,7 +2772,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( + useCommandHotkey( hotkeys.KEYFRAME_EDITOR_SPLIT, (event) => { event.preventDefault() @@ -2787,7 +2785,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor, splitView], ) - useHotkeys( + useCommandHotkey( hotkeys.COPY, (event) => { event.preventDefault() @@ -2800,7 +2798,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCopyKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( + useCommandHotkey( hotkeys.CUT, (event) => { event.preventDefault() @@ -2813,7 +2811,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCutKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( + useCommandHotkey( hotkeys.PASTE, (event) => { event.preventDefault() @@ -3690,13 +3688,6 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ propertyColumnWidth={propertyColumnWidth} shortcutsEnabled={isPointerWithinEditor || isFocusWithinEditor} addKeyframeShortcutEnabled={surface === 'edit'} - shortcuts={{ - addKeyframe: surface === 'edit' ? hotkeys.EDIT_KEYFRAME_ADD : '', - previousKeyframe: hotkeys.KEYFRAME_PREVIOUS, - nextKeyframe: hotkeys.KEYFRAME_NEXT, - toggleAutoKey: hotkeys.KEYFRAME_TOGGLE_AUTO, - fitKeyframes: hotkeys.KEYFRAME_FIT, - }} /> diff --git a/src/features/timeline/deps/settings-contract.ts b/src/features/timeline/deps/settings-contract.ts index e01af00db..6c7f53151 100644 --- a/src/features/timeline/deps/settings-contract.ts +++ b/src/features/timeline/deps/settings-contract.ts @@ -4,7 +4,4 @@ */ export { useSettingsStore } from '@/features/settings/stores/settings-store' -export { - useResolvedHotkeys, - useRuntimeHotkeys, -} from '@/features/settings/hooks/use-resolved-hotkeys' +export { useResolvedHotkeys } from '@/features/settings/hooks/use-resolved-hotkeys' diff --git a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx index 81d0c4955..7a3e15c79 100644 --- a/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useHotkeys } from 'react-hotkeys-hook' import { HOTKEY_OPTIONS, - HOTKEYS, getRuntimeHotkeyBinding, resolveHotkeys, type HotkeyBindingMap, @@ -22,15 +21,6 @@ const runtimeHotkeysOverride = vi.hoisted(() => ({ current: null as HotkeyBindingMap | null, })) -function runtimePrimaryBindings(bindings: HotkeyBindingMap): HotkeyBindingMap { - return Object.fromEntries( - (Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [ - command, - getRuntimeHotkeyBinding(bindings, command) ?? '', - ]), - ) as HotkeyBindingMap -} - const originalPlaybackActions = { togglePlayPause: usePlaybackStore.getState().togglePlayPause, shuttleForward: usePlaybackStore.getState().shuttleForward, @@ -42,8 +32,15 @@ vi.mock('@/features/timeline/deps/settings', async (importOriginal) => { return { ...actual, useResolvedHotkeys: () => runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys(), - useRuntimeHotkeys: () => - runtimePrimaryBindings(runtimeHotkeysOverride.current ?? actual.useResolvedHotkeys()), + } +}) + +vi.mock('@/hooks/use-runtime-hotkey-binding', () => { + const getBindings = () => + runtimeHotkeysOverride.current ?? resolveHotkeys(useSettingsStore.getState().hotkeyOverrides) + return { + useRuntimeHotkeyBinding: (command: HotkeyKey, variant: 'primary' | 'preview' = 'primary') => + getRuntimeHotkeyBinding(getBindings(), command, variant) ?? '', } }) @@ -175,6 +172,39 @@ describe('runtime shortcut ownership', () => { expect(shuttleReverse).not.toHaveBeenCalled() }) + it('executes exactly one action through a dead-claimant platform bridge', () => { + runtimeHotkeysOverride.current = { + ...resolveHotkeys(), + PLAY_PAUSE: 'meta+f10', + SHUTTLE_REVERSE: 'mod+f10', + SHUTTLE_PAUSE: 'ctrl+f10', + } + const togglePlayPause = vi.fn(originalPlaybackActions.togglePlayPause) + const shuttleReverse = vi.fn(originalPlaybackActions.shuttleReverse) + const pause = vi.fn() + usePlaybackStore.setState({ togglePlayPause, shuttleReverse, pause }) + render() + + const event = new KeyboardEvent('keydown', { + key: 'F10', + code: 'F10', + ctrlKey: true, + bubbles: true, + cancelable: true, + }) + const preventDefault = vi.spyOn(event, 'preventDefault') + const stopPropagation = vi.spyOn(event, 'stopPropagation') + document.dispatchEvent(event) + + expect(pause).toHaveBeenCalledTimes(1) + expect(shuttleReverse).not.toHaveBeenCalled() + expect(togglePlayPause).not.toHaveBeenCalled() + // react-hotkeys-hook applies preventDefault from HOTKEY_OPTIONS before the + // preserved winner callback applies it; the dead claimant adds no calls. + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(stopPropagation).toHaveBeenCalledTimes(1) + }) + it('gives playback sole ownership across playback and split shortcut hooks', () => { runtimeHotkeysOverride.current = { ...resolveHotkeys(), diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index d8cb476b2..13985440e 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -2,7 +2,7 @@ * Clipboard shortcuts: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { toast } from 'sonner' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' @@ -15,7 +15,6 @@ import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { Transition } from '@/types/transition' import type { TimelineItem } from '@/types/timeline' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, @@ -64,7 +63,6 @@ function revealPastedItems(itemIds: readonly string[]): void { } export function useClipboardShortcuts() { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) const selectedKeyframes = useKeyframeSelectionStore((s) => s.selectedKeyframes) @@ -87,7 +85,7 @@ export function useClipboardShortcuts() { } // Clipboard: Ctrl+C - Copy selected transition properties or timeline items - useHotkeys( + useCommandHotkey( hotkeys.COPY, (event) => { // Transcript editor copies the selected words instead of the clip. @@ -134,7 +132,7 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+X - Cut selected items immediately - useHotkeys( + useCommandHotkey( hotkeys.CUT, (event) => { // Transcript editor cuts the selected words instead of the clip. @@ -161,7 +159,7 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+V - Paste transition properties or timeline items - useHotkeys( + useCommandHotkey( hotkeys.PASTE, (event) => { if (selectedTransitionId && transitionClipboard) { diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index b67f5ad95..dbdba78b8 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -8,17 +8,15 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedMarkerId = useSelectionStore((s) => s.selectedMarkerId) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) @@ -82,8 +80,8 @@ export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Delete - Delete selected items, marker, or transition - useHotkeys(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) // Editing: Backspace - Delete selected items, marker, or transition (alternative) - useHotkeys(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) } diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 4ea024200..8b22ccbb5 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' @@ -20,12 +20,10 @@ import { import type { TransformProperties } from '@/types/transform' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useClearKeyframesDialogStore } from '@/shared/state/clear-keyframes-dialog' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' import { useDeleteShortcuts } from './use-delete-shortcuts' export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -79,7 +77,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Delete - Ripple delete selected items (delete + close gap) - useHotkeys( + useCommandHotkey( hotkeys.RIPPLE_DELETE, (event) => { if (deleteOwnedByPanel) { @@ -101,7 +99,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Backspace - Ripple delete selected items (alternative) - useHotkeys( + useCommandHotkey( hotkeys.RIPPLE_DELETE_ALT, (event) => { if (deleteOwnedByPanel) { @@ -123,7 +121,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+Arrow keys - nudge selected visual items by 1px - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_LEFT, (event) => { event.preventDefault() @@ -133,7 +131,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_RIGHT, (event) => { event.preventDefault() @@ -143,7 +141,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_UP, (event) => { event.preventDefault() @@ -153,7 +151,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_DOWN, (event) => { event.preventDefault() @@ -164,7 +162,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Cmd/Ctrl+Shift+Arrow keys - nudge selected visual items by 10px - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_LEFT_LARGE, (event) => { event.preventDefault() @@ -174,7 +172,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_RIGHT_LARGE, (event) => { event.preventDefault() @@ -184,7 +182,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_UP_LARGE, (event) => { event.preventDefault() @@ -194,7 +192,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( + useCommandHotkey( hotkeys.NUDGE_DOWN_LARGE, (event) => { event.preventDefault() @@ -205,7 +203,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+J - Join selected clips - useHotkeys( + useCommandHotkey( hotkeys.JOIN_ITEMS, (event) => { if (selectedItemIds.length < 2) return @@ -225,7 +223,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items, joinItems], ) - useHotkeys( + useCommandHotkey( hotkeys.LINK_AUDIO_VIDEO, (event) => { if (selectedItemIds.length < 2) return @@ -238,7 +236,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( + useCommandHotkey( hotkeys.UNLINK_AUDIO_VIDEO, (event) => { if (selectedItemIds.length === 0) return @@ -251,7 +249,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_LINKED_SELECTION, (event) => { event.preventDefault() @@ -269,7 +267,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { }, []) // Editing: Alt+C - Split all items at gray playhead (or main playhead) - useHotkeys( + useCommandHotkey( hotkeys.SPLIT_AT_PLAYHEAD_ALT, splitAtPlayhead, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, @@ -277,7 +275,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+F - Insert freeze frame at playhead - useHotkeys( + useCommandHotkey( hotkeys.FREEZE_FRAME, (event) => { if (selectedItemIds.length !== 1) return @@ -300,7 +298,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Keyframes: Shift+A - Clear all keyframes for selected items (with confirmation) - useHotkeys( + useCommandHotkey( hotkeys.CLEAR_KEYFRAMES, (event) => { if (selectedItemIds.length === 0) return diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index 4d57e68d5..bdba661a3 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -2,19 +2,17 @@ * Timeline in/out shortcuts: I, O, Shift+I/O, Alt+X. */ -import { useHotkeys } from 'react-hotkeys-hook' -import { HOTKEY_OPTIONS, getRuntimeHotkeyBinding } from '@/config/hotkeys' +import { + COMMAND_HOTKEYS as hotkeys, + useCommandHotkey, + useDerivedCommandHotkey, +} from '@/hooks/use-hotkey-registration' +import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' -import { useResolvedHotkeys, useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useInOutShortcuts() { - const resolvedHotkeys = useResolvedHotkeys() - const hotkeys = useRuntimeHotkeys() - const markInAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_IN', 'preview') - const markOutAtPreview = getRuntimeHotkeyBinding(resolvedHotkeys, 'MARK_OUT', 'preview') - - useHotkeys( + useCommandHotkey( hotkeys.MARK_IN, (event) => { event.preventDefault() @@ -25,18 +23,19 @@ export function useInOutShortcuts() { [], ) - useHotkeys( - markInAtPreview ?? [], + useDerivedCommandHotkey( + 'MARK_IN', + 'preview', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() useTimelineStore.getState().setInPoint(previewFrame ?? currentFrame) }, HOTKEY_OPTIONS, - [markInAtPreview], + [], ) - useHotkeys( + useCommandHotkey( hotkeys.MARK_OUT, (event) => { event.preventDefault() @@ -47,18 +46,19 @@ export function useInOutShortcuts() { [], ) - useHotkeys( - markOutAtPreview ?? [], + useDerivedCommandHotkey( + 'MARK_OUT', + 'preview', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() useTimelineStore.getState().setOutPoint(previewFrame ?? currentFrame) }, HOTKEY_OPTIONS, - [markOutAtPreview], + [], ) - useHotkeys( + useCommandHotkey( hotkeys.CLEAR_IN_OUT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index fd1339501..f2adab901 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -2,21 +2,19 @@ * Marker shortcuts: M (add), Shift+M (remove), [ ] (navigate). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { addMarker, removeMarker } from '../../stores/actions/marker-actions' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useMarkerShortcuts() { - const hotkeys = useRuntimeHotkeys() const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame) const clearSelection = useSelectionStore((s) => s.clearSelection) // Markers: M - Add marker at playhead - useHotkeys( + useCommandHotkey( hotkeys.ADD_MARKER, (event) => { event.preventDefault() @@ -28,7 +26,7 @@ export function useMarkerShortcuts() { ) // Markers: Shift+M - Remove selected marker - useHotkeys( + useCommandHotkey( hotkeys.REMOVE_MARKER, (event) => { event.preventDefault() @@ -43,7 +41,7 @@ export function useMarkerShortcuts() { ) // Markers: [ - Jump to previous marker - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_MARKER, (event) => { event.preventDefault() @@ -67,7 +65,7 @@ export function useMarkerShortcuts() { ) // Markers: ] - Jump to next marker - useHotkeys( + useCommandHotkey( hotkeys.NEXT_MARKER, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index 1f25d07fd..a69f188ab 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { useItemsStore } from '../../stores/items-store' @@ -14,7 +14,6 @@ import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' import { useSourcePlayerStore } from '@/shared/state/source-player' import { getFilteredItemSnapEdges } from '../../utils/timeline-snap-utils' import { getVisibleTrackIds } from '../../utils/group-utils' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' /** Compute snap points on-demand from current store state (avoids reactive subscriptions). */ function getSnapPoints(): number[] { @@ -35,7 +34,6 @@ function getSnapPoints(): number[] { } export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) const shuttleForward = usePlaybackStore((s) => s.shuttleForward) const shuttleReverse = usePlaybackStore((s) => s.shuttleReverse) @@ -54,7 +52,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Playback: Space - Play/Pause - useHotkeys( + useCommandHotkey( hotkeys.PLAY_PAUSE, (event) => { event.preventDefault() @@ -76,7 +74,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_FORWARD, (event) => { if (event.repeat) return @@ -98,7 +96,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_REVERSE, (event) => { if (event.repeat) return @@ -120,7 +118,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Pause always owns its binding, including while already paused, so transport // routing cannot fall through to another command. - useHotkeys( + useCommandHotkey( hotkeys.SHUTTLE_PAUSE, (event) => { if (event.repeat) return @@ -142,7 +140,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Arrow Left - Previous frame - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_FRAME, (event) => { event.preventDefault() @@ -159,7 +157,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Arrow Right - Next frame - useHotkeys( + useCommandHotkey( hotkeys.NEXT_FRAME, (event) => { event.preventDefault() @@ -176,7 +174,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Home - Go to start - useHotkeys( + useCommandHotkey( hotkeys.GO_TO_START, (event) => { event.preventDefault() @@ -192,7 +190,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: End - Go to end of timeline (last frame of last item) - useHotkeys( + useCommandHotkey( hotkeys.GO_TO_END, (event) => { event.preventDefault() @@ -213,7 +211,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Down - Jump to next snap point (clip edge or marker) - useHotkeys( + useCommandHotkey( hotkeys.NEXT_SNAP_POINT, (event) => { event.preventDefault() @@ -228,7 +226,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Up - Jump to previous snap point (clip edge or marker) - useHotkeys( + useCommandHotkey( hotkeys.PREVIOUS_SNAP_POINT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 3564ceae1..97af68c39 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -9,17 +9,14 @@ * source monitor is hovered/focused. */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' export function useSourceMonitorShortcuts() { - const hotkeys = useRuntimeHotkeys() - // Insert Edit: , (comma) — works globally when source monitor is open - useHotkeys( + useCommandHotkey( hotkeys.INSERT_EDIT, (event) => { event.preventDefault() @@ -32,7 +29,7 @@ export function useSourceMonitorShortcuts() { ) // Overwrite Edit: . (period) — works globally when source monitor is open - useHotkeys( + useCommandHotkey( hotkeys.OVERWRITE_EDIT, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index f49883515..30e833580 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -2,22 +2,20 @@ * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys } from '@/features/timeline/deps/settings' import { SLIP_SLIDE_TOOLS_ENABLED } from '../../constants' export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useRuntimeHotkeys() const activeTool = useSelectionStore((s) => s.activeTool) const setActiveTool = useSelectionStore((s) => s.setActiveTool) // Tool: V - Selection Tool - useHotkeys( + useCommandHotkey( hotkeys.SELECTION_TOOL, (event) => { event.preventDefault() @@ -28,7 +26,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: T - Toggle Trim Edit Tool - useHotkeys( + useCommandHotkey( hotkeys.TRIM_EDIT_TOOL, (event) => { event.preventDefault() @@ -39,7 +37,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: C - Toggle Razor/Cut Mode - useHotkeys( + useCommandHotkey( hotkeys.RAZOR_TOOL, (event) => { event.preventDefault() @@ -50,7 +48,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) - useHotkeys( + useCommandHotkey( hotkeys.SPLIT_AT_PLAYHEAD, (event) => { event.preventDefault() @@ -74,7 +72,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: R - Toggle Rate Stretch Tool - useHotkeys( + useCommandHotkey( hotkeys.RATE_STRETCH_TOOL, (event) => { event.preventDefault() @@ -85,7 +83,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Y - Toggle Slip Tool - useHotkeys( + useCommandHotkey( hotkeys.SLIP_TOOL, (event) => { event.preventDefault() @@ -96,7 +94,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: U - Toggle Slide Tool - useHotkeys( + useCommandHotkey( hotkeys.SLIDE_TOOL, (event) => { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index 3d4f9f14b..379d5cfa9 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -2,13 +2,13 @@ * UI shortcuts: S (snap toggle), Cmd/Ctrl+=/- (zoom), \\ (zoom to fit), Shift+\\ or Cmd/Ctrl+0 (zoom to 100%), Undo/Redo. */ -import { useHotkeys } from 'react-hotkeys-hook' +import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useTimelineStore } from '../../stores/timeline-store' import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useRuntimeHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { useSettingsStore } from '@/features/timeline/deps/settings' export interface UIShortcutOptions { /** @@ -23,13 +23,12 @@ export function useUIShortcuts( options: UIShortcutOptions = {}, ) { const { enableHistory = true } = options - const hotkeys = useRuntimeHotkeys() const toggleSnap = useTimelineStore((s) => s.toggleSnap) const zoomIn = useZoomStore((s) => s.zoomIn) const zoomOut = useZoomStore((s) => s.zoomOut) // History: Cmd/Ctrl+Z - Undo - useHotkeys( + useCommandHotkey( hotkeys.UNDO, (event) => { event.preventDefault() @@ -47,7 +46,7 @@ export function useUIShortcuts( ) // History: Cmd/Ctrl+Shift+Z - Redo - useHotkeys( + useCommandHotkey( hotkeys.REDO, (event) => { event.preventDefault() @@ -65,7 +64,7 @@ export function useUIShortcuts( ) // UI: S - Toggle Snap - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_SNAP, (event) => { event.preventDefault() @@ -76,7 +75,7 @@ export function useUIShortcuts( ) // UI: Shift+S - Toggle Canvas (gizmo) Snap — independent from timeline snap. - useHotkeys( + useCommandHotkey( hotkeys.TOGGLE_CANVAS_SNAP, (event) => { event.preventDefault() @@ -90,7 +89,7 @@ export function useUIShortcuts( const zoomHotkeyOptions = { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } } // Zoom: Cmd/Ctrl+Equals - Zoom in - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_IN, (event) => { event.preventDefault() @@ -101,7 +100,7 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+Minus - Zoom out - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_OUT, (event) => { event.preventDefault() @@ -112,7 +111,7 @@ export function useUIShortcuts( ) // Zoom: Backslash - Zoom to Fit - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_FIT, (event) => { event.preventDefault() @@ -144,7 +143,7 @@ export function useUIShortcuts( ) // Zoom: Shift+Backslash - Zoom to 100% centered on cursor (or playhead if cursor not on timeline) - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_100, (event) => { event.preventDefault() @@ -161,7 +160,7 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+0 - Reset timeline zoom to 100% - useHotkeys( + useCommandHotkey( hotkeys.ZOOM_TO_100_ALT, (event) => { event.preventDefault() diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts new file mode 100644 index 000000000..1c83bfe54 --- /dev/null +++ b/src/hooks/use-hotkey-registration.ts @@ -0,0 +1,68 @@ +import type { DependencyList } from 'react' +import { useHotkeys, type HotkeyCallback, type Options } from 'react-hotkeys-hook' +import { HOTKEYS, HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' +import { useRuntimeHotkeyBinding } from './use-runtime-hotkey-binding' + +type HotkeyOptionsOrDependencies = Options | DependencyList +export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' + +/** Command identifiers only; values never contain display or persisted bindings. */ +export const COMMAND_HOTKEYS = Object.freeze( + Object.fromEntries((Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [command, command])), +) as Readonly> + +const LOCAL_HOTKEY_BINDINGS = { + DOPESHEET_DELETE: 'delete,backspace', + DOPESHEET_NUDGE_LEFT: 'left', + DOPESHEET_NUDGE_RIGHT: 'right', + DOPESHEET_NUDGE_LEFT_LARGE: 'shift+left', + DOPESHEET_NUDGE_RIGHT_LARGE: 'shift+right', +} as const + +export type LocalHotkeyKey = keyof typeof LOCAL_HOTKEY_BINDINGS + +/** Runtime-only command binding. Display and persistence must use resolved maps instead. */ +export function useCommandHotkeyBinding(command: HotkeyKey): string { + return useRuntimeHotkeyBinding(command) +} + +function useDerivedCommandHotkeyBinding(command: DerivedHotkeyCommand): string { + return useRuntimeHotkeyBinding(command, 'preview') +} + +/** The sole production registration path for primary command hotkeys. */ +export function useCommandHotkey( + command: HotkeyKey, + callback: HotkeyCallback, + options: HotkeyOptionsOrDependencies = HOTKEY_OPTIONS, + dependencies?: HotkeyOptionsOrDependencies, +) { + const binding = useCommandHotkeyBinding(command) + return useHotkeys(binding, callback, options, dependencies) +} + +/** Typed registration path for centrally owned modifier-derived command variants. */ +export function useDerivedCommandHotkey( + command: DerivedHotkeyCommand, + _variant: 'preview', + callback: HotkeyCallback, + options: HotkeyOptionsOrDependencies = HOTKEY_OPTIONS, + dependencies?: HotkeyOptionsOrDependencies, +) { + const binding = useDerivedCommandHotkeyBinding(command) + return useHotkeys(binding, callback, options, dependencies) +} + +/** + * Low-level, non-command bindings local to the dopesheet. Callers choose a + * closed local key, so command maps and HotkeyKey-derived strings cannot enter + * this API. + */ +export function useLocalHotkey( + localKey: LocalHotkeyKey, + callback: HotkeyCallback, + options?: HotkeyOptionsOrDependencies, + dependencies?: HotkeyOptionsOrDependencies, +) { + return useHotkeys(LOCAL_HOTKEY_BINDINGS[localKey], callback, options, dependencies) +} diff --git a/src/hooks/use-runtime-hotkey-binding.ts b/src/hooks/use-runtime-hotkey-binding.ts new file mode 100644 index 000000000..a2de0fa2d --- /dev/null +++ b/src/hooks/use-runtime-hotkey-binding.ts @@ -0,0 +1,46 @@ +import { + getRuntimeHotkeyBinding, + resolveHotkeys, + resolveRuntimeHotkeys, + type HotkeyBindingMap, + type HotkeyKey, + type HotkeyOverrideMap, +} from '@/config/hotkeys' +import { useSettingsStore } from '@/features/settings/stores/settings-store' + +interface RuntimeHotkeySnapshot { + primary: HotkeyBindingMap + preview: Record<'MARK_IN' | 'MARK_OUT', string> +} + +let cachedOverrides: HotkeyOverrideMap | null = null +let cachedRuntimeSnapshot: RuntimeHotkeySnapshot | null = null + +function getRuntimeHotkeySnapshot(overrides: HotkeyOverrideMap): RuntimeHotkeySnapshot { + if (cachedOverrides === overrides && cachedRuntimeSnapshot) return cachedRuntimeSnapshot + + const resolved = resolveHotkeys(overrides) + cachedOverrides = overrides + cachedRuntimeSnapshot = { + primary: resolveRuntimeHotkeys(resolved), + preview: { + MARK_IN: getRuntimeHotkeyBinding(resolved, 'MARK_IN', 'preview') ?? '', + MARK_OUT: getRuntimeHotkeyBinding(resolved, 'MARK_OUT', 'preview') ?? '', + }, + } + return cachedRuntimeSnapshot +} + +/** Runtime-only selector; display and persistence must use resolved maps instead. */ +export function useRuntimeHotkeyBinding( + command: HotkeyKey, + variant: 'primary' | 'preview' = 'primary', +): string { + return useSettingsStore((state) => { + const snapshot = getRuntimeHotkeySnapshot(state.hotkeyOverrides) + if (variant === 'preview' && (command === 'MARK_IN' || command === 'MARK_OUT')) { + return snapshot.preview[command] + } + return snapshot.primary[command] + }) +} From dc40a835a07cadf540abf5fcfb9ed577338f7f08 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 05:19:47 -0700 Subject: [PATCH 15/21] fix hotkey registration gates --- package-lock.json | 1 - package.json | 1 - scripts/runtime-hotkey-import-boundary.d.mts | 2 + scripts/runtime-hotkey-import-boundary.mjs | 121 +++++++++++------- ...ntime-hotkey-registration-coverage.test.ts | 59 ++++++--- .../timeline-item/item-context-menu.test.tsx | 5 + src/hooks/use-hotkey-registration.ts | 14 +- 7 files changed, 134 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index d928ec33e..f32878f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "zustand": "5.0.12" }, "devDependencies": { - "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/package.json b/package.json index db2a46ab0..1f84b65e2 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,6 @@ "zustand": "5.0.12" }, "devDependencies": { - "@babel/parser": "7.29.7", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.166.33", "@testing-library/dom": "10.4.1", diff --git a/scripts/runtime-hotkey-import-boundary.d.mts b/scripts/runtime-hotkey-import-boundary.d.mts index d7006a2c9..8fb226982 100644 --- a/scripts/runtime-hotkey-import-boundary.d.mts +++ b/scripts/runtime-hotkey-import-boundary.d.mts @@ -7,6 +7,8 @@ export interface RuntimeHotkeyImportViolation { path: string line: number column: number + allowedPath: string + message: string } export declare const RUNTIME_HOTKEY_ADAPTER_PATH: 'src/hooks/use-hotkey-registration.ts' diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index a5e5f8583..5693e7fae 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -1,64 +1,55 @@ -import { parse } from '@babel/parser' +import { API } from 'typescript/unstable/sync' +import { createVirtualFileSystem } from 'typescript/unstable/fs' +import { + SyntaxKind, + isCallExpression, + isExportDeclaration, + isExternalModuleReference, + isIdentifier, + isImportDeclaration, + isImportEqualsDeclaration, + isStringLiteral, +} from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' function isReactHotkeysSource(source) { - return source?.type === 'StringLiteral' && source.value === REACT_HOTKEYS_HOOK_MODULE + return isStringLiteral(source) && source.text === REACT_HOTKEYS_HOOK_MODULE } function isStaticReactHotkeysImport(node) { - const hasStaticSource = - node.type === 'ImportDeclaration' || - node.type === 'ExportNamedDeclaration' || - node.type === 'ExportAllDeclaration' - return hasStaticSource && isReactHotkeysSource(node.source) + return ( + (isImportDeclaration(node) || isExportDeclaration(node)) && + isReactHotkeysSource(node.moduleSpecifier) + ) } function isTypeScriptReactHotkeysImport(node) { - if (node.type !== 'TSImportEqualsDeclaration') return false + if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return ( - reference.type === 'TSExternalModuleReference' && isReactHotkeysSource(reference.expression) - ) + return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression) } function isReactHotkeysCallImport(node) { - if (node.type !== 'CallExpression') return false - const { callee, arguments: args } = node - const isRequire = callee.type === 'Identifier' && callee.name === 'require' + if (!isCallExpression(node)) return false + const { expression, arguments: args } = node + const isRequire = isIdentifier(expression) && expression.text === 'require' + const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( - (isRequire || callee.type === 'Import') && args.length === 1 && isReactHotkeysSource(args[0]) + (isRequire || isDynamicImport) && args.length === 1 && isReactHotkeysSource(args[0]) ) } -function isReactHotkeysImportExpression(node) { - return node.type === 'ImportExpression' && isReactHotkeysSource(node.source) -} - const IMPORT_NODE_CHECKS = [ isStaticReactHotkeysImport, isTypeScriptReactHotkeysImport, isReactHotkeysCallImport, - isReactHotkeysImportExpression, ] -const AST_METADATA_KEYS = new Set(['loc', 'start', 'end']) - -function walkAst(root, onNode) { - const pending = [root] - while (pending.length > 0) { - const node = pending.pop() - if (!node || typeof node !== 'object') continue - if (Array.isArray(node)) { - pending.push(...node) - continue - } - onNode(node) - for (const [key, child] of Object.entries(node)) { - if (!AST_METADATA_KEYS.has(key)) pending.push(child) - } - } +function walkAst(node, onNode) { + onNode(node) + node.forEachChild((child) => walkAst(child, onNode)) } export function findReactHotkeysHookImportViolations( @@ -66,26 +57,58 @@ export function findReactHotkeysHookImportViolations( allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, ) { const violations = [] + // Every import form we reject must contain the literal module specifier. This + // prefilter keeps the compiler AST focused on the one or two relevant files. + const candidates = sources.filter(({ source }) => source.includes(REACT_HOTKEYS_HOOK_MODULE)) + if (candidates.length === 0) return violations + + const virtualRoot = '/runtime-hotkey-import-boundary' + const virtualSources = new Map() + const virtualFiles = Object.fromEntries( + candidates.map((candidate, index) => { + const extension = candidate.path.endsWith('.tsx') ? 'tsx' : 'ts' + const virtualPath = `${virtualRoot}/source-${index}.${extension}` + virtualSources.set(virtualPath, candidate) + return [virtualPath, candidate.source] + }), + ) + virtualFiles[`${virtualRoot}/tsconfig.json`] = JSON.stringify({ + compilerOptions: { jsx: 'preserve', noLib: true }, + files: [...virtualSources.keys()], + }) - for (const { path, source } of sources) { - const ast = parse(source, { - sourceType: 'unambiguous', - plugins: ['typescript', 'jsx', 'dynamicImport'], - }) + const compiler = new API({ cwd: virtualRoot, fs: createVirtualFileSystem(virtualFiles) }) + let snapshot - function record(node) { - if (path !== allowedPath) { + try { + snapshot = compiler.updateSnapshot({ openProjects: [`${virtualRoot}/tsconfig.json`] }) + const project = snapshot.getProjects()[0] + + for (const [virtualPath, { path }] of virtualSources) { + const sourceFile = project?.program.getSourceFile(virtualPath) + if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) + + function record(node) { + if (path === allowedPath) return + const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + const line = location.line + 1 + const column = location.character + 1 violations.push({ path, - line: node.loc?.start.line ?? 1, - column: (node.loc?.start.column ?? 0) + 1, + line, + column, + allowedPath, + message: `${path}:${line}:${column} imports ${REACT_HOTKEYS_HOOK_MODULE}; use ${allowedPath}`, }) } - } - walkAst(ast, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) - }) + walkAst(sourceFile, (node) => { + if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + }) + } + } finally { + snapshot?.dispose() + compiler.close() } return violations.sort( diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index e3c3d5542..5201acbc4 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -19,6 +19,14 @@ function productionSourceFiles(directory: string): string[] { }) } +function formatViolations( + violations: ReturnType, +): string { + return violations.length === 0 + ? 'No runtime hotkey import violations' + : violations.map(({ message }) => message).join('\n') +} + describe('runtime hotkey registration coverage', () => { it('allows react-hotkeys-hook only in the production registration adapter', () => { const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ @@ -26,21 +34,34 @@ describe('runtime hotkey registration coverage', () => { source: readFileSync(path, 'utf8'), })) - expect(findReactHotkeysHookImportViolations(sources)).toEqual([]) + const violations = findReactHotkeysHookImportViolations(sources) + expect(violations, formatViolations(violations)).toEqual([]) }) - it.each([ - ['aliased static import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], - ['default import', "import hotkeyHooks from 'react-hotkeys-hook'"], - ['namespace import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], - ['destructured require', "const { useHotkeys } = require('react-hotkeys-hook')"], - ['TypeScript import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], - ['wrapper re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], - ['dynamic import', "const hooks = await import('react-hotkeys-hook')"], - ])('rejects a %s bypass', (_label, source) => { - expect( - findReactHotkeysHookImportViolations([{ path: 'src/features/bypass.ts', source }]), - ).toEqual([expect.objectContaining({ path: 'src/features/bypass.ts', line: 1 })]) + it('rejects in-memory AST fixtures for every supported bypass form', () => { + const fixtures: Array<[string, string]> = [ + ['side-effect-static-import', "import 'react-hotkeys-hook'"], + ['aliased-static-import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], + ['default-import', "import hotkeyHooks from 'react-hotkeys-hook'"], + ['namespace-import', "import * as hotkeyHooks from 'react-hotkeys-hook'"], + ['destructured-require', "const { useHotkeys } = require('react-hotkeys-hook')"], + ['typescript-import-equals', "import hotkeyHooks = require('react-hotkeys-hook')"], + ['wrapper-re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], + ['export-all', "export * from 'react-hotkeys-hook'"], + ['dynamic-import', "const hooks = await import('react-hotkeys-hook')"], + ] + const sources = fixtures.map(([name, source]) => ({ + path: `src/features/${name}.ts`, + source, + })) + + expect(findReactHotkeysHookImportViolations(sources)).toEqual( + sources + .toSorted((left, right) => left.path.localeCompare(right.path)) + .map(({ path }) => + expect.objectContaining({ path, line: 1, allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH }), + ), + ) }) it('allows the exact adapter module and no similarly named wrapper', () => { @@ -48,10 +69,18 @@ describe('runtime hotkey registration coverage', () => { expect( findReactHotkeysHookImportViolations([{ path: RUNTIME_HOTKEY_ADAPTER_PATH, source }]), ).toEqual([]) + const wrapperPath = 'src/hooks/use-hotkey-registration-wrapper.ts' expect( findReactHotkeysHookImportViolations([ - { path: 'src/hooks/use-hotkey-registration-wrapper.ts', source }, + { path: RUNTIME_HOTKEY_ADAPTER_PATH, source }, + { path: wrapperPath, source }, ]), - ).toHaveLength(1) + ).toEqual([ + expect.objectContaining({ + path: wrapperPath, + allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH, + message: expect.stringContaining(RUNTIME_HOTKEY_ADAPTER_PATH), + }), + ]) }) }) diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 3b39ed0d8..2f20a277b 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, screen } from '@testing-library/react' +import { COMMAND_HOTKEYS } from '@/hooks/use-hotkey-registration' import { useSelectionStore } from '@/shared/state/selection' import { ItemContextMenu } from './item-context-menu' @@ -116,6 +117,10 @@ describe('ItemContextMenu scene detection', () => { }) }) + it('keeps command identifiers usable with the existing partial hotkey config mock', () => { + expect(COMMAND_HOTKEYS.DELETE_SELECTED).toBe('DELETE_SELECTED') + }) + it('keeps the menu non-modal so dialog handoffs cannot strand pointer blocking', () => { renderContextMenu() diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts index 1c83bfe54..3d6dc2f4c 100644 --- a/src/hooks/use-hotkey-registration.ts +++ b/src/hooks/use-hotkey-registration.ts @@ -7,9 +7,17 @@ type HotkeyOptionsOrDependencies = Options | DependencyList export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' /** Command identifiers only; values never contain display or persisted bindings. */ -export const COMMAND_HOTKEYS = Object.freeze( - Object.fromEntries((Object.keys(HOTKEYS) as HotkeyKey[]).map((command) => [command, command])), -) as Readonly> +export const COMMAND_HOTKEYS = new Proxy({} as Record, { + get: (_target, command) => (typeof command === 'string' ? (command as HotkeyKey) : undefined), + ownKeys: () => Object.keys(HOTKEYS), + getOwnPropertyDescriptor: (_target, command) => + typeof command === 'string' && Object.hasOwn(HOTKEYS, command) + ? { configurable: true, enumerable: true, value: command, writable: false } + : undefined, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false, +}) as Readonly> const LOCAL_HOTKEY_BINDINGS = { DOPESHEET_DELETE: 'delete,backspace', From 026c62d4960bd8a944bafb121fbeca213d8c5b5a Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 05:57:03 -0700 Subject: [PATCH 16/21] fix hotkey proxy and import boundary --- scripts/runtime-hotkey-import-boundary.mjs | 144 ++++++++++++++++-- ...ntime-hotkey-registration-coverage.test.ts | 81 ++++++---- .../editor/hooks/use-editor-hotkeys.ts | 14 +- .../components/dopesheet-editor/index.tsx | 16 +- .../components/keyframe-graph-panel.tsx | 14 +- .../timeline-item/item-context-menu.test.tsx | 5 - .../shortcuts/use-clipboard-shortcuts.ts | 8 +- .../hooks/shortcuts/use-delete-shortcuts.ts | 6 +- .../hooks/shortcuts/use-editing-shortcuts.ts | 36 ++--- .../hooks/shortcuts/use-in-out-shortcuts.ts | 12 +- .../hooks/shortcuts/use-marker-shortcuts.ts | 10 +- .../hooks/shortcuts/use-playback-shortcuts.ts | 22 +-- .../shortcuts/use-source-monitor-shortcuts.ts | 6 +- .../hooks/shortcuts/use-tool-shortcuts.ts | 16 +- .../hooks/shortcuts/use-ui-shortcuts.ts | 20 +-- src/hooks/use-hotkey-registration.test.ts | 20 +++ src/hooks/use-hotkey-registration.ts | 15 +- 17 files changed, 291 insertions(+), 154 deletions(-) create mode 100644 src/hooks/use-hotkey-registration.test.ts diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 5693e7fae..a043ae967 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -1,43 +1,114 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' import { API } from 'typescript/unstable/sync' import { createVirtualFileSystem } from 'typescript/unstable/fs' import { SyntaxKind, + NodeFlags, + isAsExpression, + isBinaryExpression, isCallExpression, isExportDeclaration, isExternalModuleReference, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, + isNoSubstitutionTemplateLiteral, + isParenthesizedExpression, + isSatisfiesExpression, isStringLiteral, + isTemplateExpression, + isTypeAssertion, + isVariableStatement, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' -function isReactHotkeysSource(source) { - return isStringLiteral(source) && source.text === REACT_HOTKEYS_HOOK_MODULE +const CONSTANT_STRING_WRAPPER_CHECKS = [ + isParenthesizedExpression, + isAsExpression, + isSatisfiesExpression, + isTypeAssertion, +] + +function evaluateTemplateString(expression, constantBindings, resolving) { + let value = expression.head.text + for (const span of expression.templateSpans) { + const interpolation = evaluateConstantString(span.expression, constantBindings, resolving) + if (interpolation === undefined) return undefined + value += interpolation + span.literal.text + } + return value +} + +function evaluateConcatenatedString(expression, constantBindings, resolving) { + if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined + const left = evaluateConstantString(expression.left, constantBindings, resolving) + const right = evaluateConstantString(expression.right, constantBindings, resolving) + return left === undefined || right === undefined ? undefined : left + right +} + +function evaluateConstantBinding(expression, constantBindings, resolving) { + if (!constantBindings.has(expression.text) || resolving.has(expression.text)) return undefined + const nextResolving = new Set(resolving).add(expression.text) + return evaluateConstantString( + constantBindings.get(expression.text), + constantBindings, + nextResolving, + ) +} + +function evaluateConstantString(expression, constantBindings, resolving = new Set()) { + if (!expression) return undefined + if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { + return expression.text + } + if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { + return evaluateConstantString(expression.expression, constantBindings, resolving) + } + if (isTemplateExpression(expression)) { + return evaluateTemplateString(expression, constantBindings, resolving) + } + if (isBinaryExpression(expression)) { + return evaluateConcatenatedString(expression, constantBindings, resolving) + } + if (isIdentifier(expression)) { + return evaluateConstantBinding(expression, constantBindings, resolving) + } + return undefined +} + +function isReactHotkeysSource(source, constantBindings) { + return evaluateConstantString(source, constantBindings) === REACT_HOTKEYS_HOOK_MODULE } -function isStaticReactHotkeysImport(node) { +function isStaticReactHotkeysImport(node, constantBindings) { return ( (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier) + isReactHotkeysSource(node.moduleSpecifier, constantBindings) ) } -function isTypeScriptReactHotkeysImport(node) { +function isTypeScriptReactHotkeysImport(node, constantBindings) { if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression) + return ( + isExternalModuleReference(reference) && + isReactHotkeysSource(reference.expression, constantBindings) + ) } -function isReactHotkeysCallImport(node) { +function isReactHotkeysCallImport(node, constantBindings) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node const isRequire = isIdentifier(expression) && expression.text === 'require' const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( - (isRequire || isDynamicImport) && args.length === 1 && isReactHotkeysSource(args[0]) + (isRequire || isDynamicImport) && + args.length === 1 && + isReactHotkeysSource(args[0], constantBindings) ) } @@ -52,20 +123,32 @@ function walkAst(node, onNode) { node.forEachChild((child) => walkAst(child, onNode)) } +function topLevelConstantBindings(sourceFile) { + const bindings = new Map() + for (const statement of sourceFile.statements) { + if (!isVariableStatement(statement)) continue + const declarationList = statement.declarationList + if (!(declarationList.flags & NodeFlags.Const)) continue + for (const declaration of declarationList.declarations) { + if (isIdentifier(declaration.name) && declaration.initializer) { + bindings.set(declaration.name.text, declaration.initializer) + } + } + } + return bindings +} + export function findReactHotkeysHookImportViolations( sources, allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, ) { const violations = [] - // Every import form we reject must contain the literal module specifier. This - // prefilter keeps the compiler AST focused on the one or two relevant files. - const candidates = sources.filter(({ source }) => source.includes(REACT_HOTKEYS_HOOK_MODULE)) - if (candidates.length === 0) return violations + if (sources.length === 0) return violations const virtualRoot = '/runtime-hotkey-import-boundary' const virtualSources = new Map() const virtualFiles = Object.fromEntries( - candidates.map((candidate, index) => { + sources.map((candidate, index) => { const extension = candidate.path.endsWith('.tsx') ? 'tsx' : 'ts' const virtualPath = `${virtualRoot}/source-${index}.${extension}` virtualSources.set(virtualPath, candidate) @@ -87,6 +170,7 @@ export function findReactHotkeysHookImportViolations( for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) + const constantBindings = topLevelConstantBindings(sourceFile) function record(node) { if (path === allowedPath) return @@ -103,7 +187,7 @@ export function findReactHotkeysHookImportViolations( } walkAst(sourceFile, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node))) record(node) + if (IMPORT_NODE_CHECKS.some((check) => check(node, constantBindings))) record(node) }) } } finally { @@ -116,3 +200,35 @@ export function findReactHotkeysHookImportViolations( left.path.localeCompare(right.path) || left.line - right.line || left.column - right.column, ) } + +function productionSourceFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return productionSourceFiles(path) + if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] + return [path] + }) +} + +function runCli() { + const root = process.cwd() + const files = productionSourceFiles(join(root, 'src')) + const sources = files.map((path) => ({ + path: relative(root, path).split(sep).join('/'), + source: readFileSync(path, 'utf8'), + })) + const violations = findReactHotkeysHookImportViolations(sources) + + if (violations.length > 0) { + console.error(violations.map(({ message }) => message).join('\n')) + process.exitCode = 1 + return + } + + console.log( + `Runtime hotkey import boundary passed (${files.length} source files; allowed adapter: ${RUNTIME_HOTKEY_ADAPTER_PATH})`, + ) +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined +if (invokedPath === fileURLToPath(import.meta.url)) runCli() diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 5201acbc4..7ba875754 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -1,45 +1,32 @@ // @vitest-environment node -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative } from 'node:path' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' import { describe, expect, it } from 'vite-plus/test' import { RUNTIME_HOTKEY_ADAPTER_PATH, findReactHotkeysHookImportViolations, } from '../../scripts/runtime-hotkey-import-boundary.mjs' -const SRC_ROOT = join(process.cwd(), 'src') - -function productionSourceFiles(directory: string): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const path = join(directory, entry.name) - if (entry.isDirectory()) return productionSourceFiles(path) - if (!/\.tsx?$/.test(entry.name) || /\.test\./.test(entry.name)) return [] - return [path] - }) -} - -function formatViolations( - violations: ReturnType, -): string { - return violations.length === 0 - ? 'No runtime hotkey import violations' - : violations.map(({ message }) => message).join('\n') -} +const BOUNDARY_SCRIPT = join(process.cwd(), 'scripts/runtime-hotkey-import-boundary.mjs') describe('runtime hotkey registration coverage', () => { - it('allows react-hotkeys-hook only in the production registration adapter', () => { - const sources = productionSourceFiles(SRC_ROOT).map((path) => ({ - path: relative(process.cwd(), path), - source: readFileSync(path, 'utf8'), - })) + it('checks the full source tree in a standalone Node process', () => { + const startedAt = performance.now() + const result = spawnSync(process.execPath, [BOUNDARY_SCRIPT], { + cwd: process.cwd(), + encoding: 'utf8', + }) + const elapsedMs = performance.now() - startedAt - const violations = findReactHotkeysHookImportViolations(sources) - expect(violations, formatViolations(violations)).toEqual([]) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toContain(`allowed adapter: ${RUNTIME_HOTKEY_ADAPTER_PATH}`) + expect(elapsedMs).toBeLessThan(5_000) }) it('rejects in-memory AST fixtures for every supported bypass form', () => { const fixtures: Array<[string, string]> = [ + ['escaped-static-import', "import 'react-hotkeys-\\u0068ook'"], ['side-effect-static-import', "import 'react-hotkeys-hook'"], ['aliased-static-import', "import { useHotkeys as register } from 'react-hotkeys-hook'"], ['default-import', "import hotkeyHooks from 'react-hotkeys-hook'"], @@ -49,6 +36,18 @@ describe('runtime hotkey registration coverage', () => { ['wrapper-re-export', "export { useHotkeys as useWrappedHotkey } from 'react-hotkeys-hook'"], ['export-all', "export * from 'react-hotkeys-hook'"], ['dynamic-import', "const hooks = await import('react-hotkeys-hook')"], + ['template-dynamic-import', 'const hooks = await import(`react-hotkeys-hook`)'], + ['interpolated-constant-template', "const hooks = await import(`react-${'hotkeys-'}hook`)"], + ['concatenated-dynamic-import', "const hooks = await import('react-hotkeys-' + 'hook')"], + ['nested-parentheses', "const hooks = await import(((('react-hotkeys-') + ('hook'))))"], + [ + 'typescript-expression-wrappers', + "const hooks = await import((('react-hotkeys-' as string) + ('hook' satisfies string)))", + ], + [ + 'verified-rolldown-const-identifier', + "const moduleName = 'react-hotkeys-hook'; const hooks = await import(moduleName)", + ], ] const sources = fixtures.map(([name, source]) => ({ path: `src/features/${name}.ts`, @@ -64,6 +63,34 @@ describe('runtime hotkey registration coverage', () => { ) }) + it('does not trap text or non-constant module expressions', () => { + const source = ` + // import('react-hotkeys-hook') + const documentation = "require('react-hotkeys-hook')" + const moduleName = getModuleName() + const hooks = await import(moduleName) + ` + + expect( + findReactHotkeysHookImportViolations([{ path: 'src/features/documentation.ts', source }]), + ).toEqual([]) + }) + + it('reports the exact source location and allowed adapter', () => { + const path = 'src/features/multiline-import.ts' + const source = "// setup\nconst hooks = await import('react-hotkeys-hook')" + + expect(findReactHotkeysHookImportViolations([{ path, source }])).toEqual([ + { + path, + line: 2, + column: 21, + allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH, + message: `${path}:2:21 imports react-hotkeys-hook; use ${RUNTIME_HOTKEY_ADAPTER_PATH}`, + }, + ]) + }) + it('allows the exact adapter module and no similarly named wrapper', () => { const source = "import { useHotkeys } from 'react-hotkeys-hook'" expect( diff --git a/src/features/editor/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index 6b70a11d8..e84395c61 100644 --- a/src/features/editor/hooks/use-editor-hotkeys.ts +++ b/src/features/editor/hooks/use-editor-hotkeys.ts @@ -1,4 +1,4 @@ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' @@ -27,7 +27,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Save: Cmd/Ctrl+S useCommandHotkey( - hotkeys.SAVE, + 'SAVE', (event) => { event.preventDefault() if (callbacks.onSave) { @@ -40,7 +40,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Export: Cmd/Ctrl+Shift+E useCommandHotkey( - hotkeys.EXPORT, + 'EXPORT', (event) => { event.preventDefault() if (callbacks.onExport) { @@ -55,7 +55,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // default browser binding is a no-op here but Chrome will still eat it // if our listener is in bubbling phase. useCommandHotkey( - hotkeys.OPEN_SCENE_BROWSER, + 'OPEN_SCENE_BROWSER', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -68,7 +68,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { // Workspace switching: Alt+1 (Edit), Alt+2 (Color), Alt+3 (Motion). // WORKSPACE_ANIMATE retains its persisted command id for shortcut migration. useCommandHotkey( - hotkeys.WORKSPACE_EDIT, + 'WORKSPACE_EDIT', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -79,7 +79,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) useCommandHotkey( - hotkeys.WORKSPACE_COLOR, + 'WORKSPACE_COLOR', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -90,7 +90,7 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) useCommandHotkey( - hotkeys.WORKSPACE_ANIMATE, + 'WORKSPACE_ANIMATE', (event) => { if (!enableLocalUi) return event.preventDefault() diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx index 6b751e6ae..e91e5e375 100644 --- a/src/features/keyframes/components/dopesheet-editor/index.tsx +++ b/src/features/keyframes/components/dopesheet-editor/index.tsx @@ -17,11 +17,7 @@ import { } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { - COMMAND_HOTKEYS as hotkeys, - useCommandHotkey, - useLocalHotkey, -} from '@/hooks/use-hotkey-registration' +import { useCommandHotkey, useLocalHotkey } from '@/hooks/use-hotkey-registration' import { ChevronDown, ChevronLeft, @@ -2542,7 +2538,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ : undefined useCommandHotkey( - hotkeys.EDIT_KEYFRAME_ADD, + 'EDIT_KEYFRAME_ADD', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2567,7 +2563,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_PREVIOUS, + 'KEYFRAME_PREVIOUS', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2582,7 +2578,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_NEXT, + 'KEYFRAME_NEXT', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2597,7 +2593,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_TOGGLE_AUTO, + 'KEYFRAME_TOGGLE_AUTO', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2612,7 +2608,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ) useCommandHotkey( - hotkeys.KEYFRAME_FIT, + 'KEYFRAME_FIT', (event) => { event.preventDefault() fitKeyframesInView() diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index da7f40eb1..6f33e7584 100644 --- a/src/features/timeline/components/keyframe-graph-panel.tsx +++ b/src/features/timeline/components/keyframe-graph-panel.tsx @@ -17,7 +17,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { Maximize2, Minimize2, X } from 'lucide-react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' @@ -2747,7 +2747,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ // The view-mode toggle is always visible now, so the hotkeys map to it in // every context (including the Animate workspace's split-capable toggle). useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_GRAPH, + 'KEYFRAME_EDITOR_GRAPH', (event) => { event.preventDefault() setEditorMode('graph') @@ -2760,7 +2760,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_DOPESHEET, + 'KEYFRAME_EDITOR_DOPESHEET', (event) => { event.preventDefault() setEditorMode('dopesheet') @@ -2773,7 +2773,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.KEYFRAME_EDITOR_SPLIT, + 'KEYFRAME_EDITOR_SPLIT', (event) => { event.preventDefault() setEditorMode('split') @@ -2786,7 +2786,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.COPY, + 'COPY', (event) => { event.preventDefault() handleCopyKeyframes() @@ -2799,7 +2799,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.CUT, + 'CUT', (event) => { event.preventDefault() handleCutKeyframes() @@ -2812,7 +2812,7 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ ) useCommandHotkey( - hotkeys.PASTE, + 'PASTE', (event) => { event.preventDefault() handlePasteKeyframes() diff --git a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx index 2f20a277b..3b39ed0d8 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.test.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.test.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { fireEvent, render, screen } from '@testing-library/react' -import { COMMAND_HOTKEYS } from '@/hooks/use-hotkey-registration' import { useSelectionStore } from '@/shared/state/selection' import { ItemContextMenu } from './item-context-menu' @@ -117,10 +116,6 @@ describe('ItemContextMenu scene detection', () => { }) }) - it('keeps command identifiers usable with the existing partial hotkey config mock', () => { - expect(COMMAND_HOTKEYS.DELETE_SELECTED).toBe('DELETE_SELECTED') - }) - it('keeps the menu non-modal so dialog handoffs cannot strand pointer blocking', () => { renderContextMenu() diff --git a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts index 13985440e..57b394089 100644 --- a/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.ts @@ -2,7 +2,7 @@ * Clipboard shortcuts: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { toast } from 'sonner' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' @@ -86,7 +86,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+C - Copy selected transition properties or timeline items useCommandHotkey( - hotkeys.COPY, + 'COPY', (event) => { // Transcript editor copies the selected words instead of the clip. if (handleTranscriptClipboardCopy(false)) { @@ -133,7 +133,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+X - Cut selected items immediately useCommandHotkey( - hotkeys.CUT, + 'CUT', (event) => { // Transcript editor cuts the selected words instead of the clip. if (handleTranscriptClipboardCopy(true)) { @@ -160,7 +160,7 @@ export function useClipboardShortcuts() { // Clipboard: Ctrl+V - Paste transition properties or timeline items useCommandHotkey( - hotkeys.PASTE, + 'PASTE', (event) => { if (selectedTransitionId && transitionClipboard) { event.preventDefault() diff --git a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts index dbdba78b8..9ef20b096 100644 --- a/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-delete-shortcuts.ts @@ -8,7 +8,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' @@ -80,8 +80,8 @@ export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Delete - Delete selected items, marker, or transition - useCommandHotkey(hotkeys.DELETE_SELECTED, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey('DELETE_SELECTED', deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) // Editing: Backspace - Delete selected items, marker, or transition (alternative) - useCommandHotkey(hotkeys.DELETE_SELECTED_ALT, deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) + useCommandHotkey('DELETE_SELECTED_ALT', deleteSelection, HOTKEY_OPTIONS, [deleteSelection]) } diff --git a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts index 8b22ccbb5..f541c0efa 100644 --- a/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-editing-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useTimelineStore } from '../../stores/timeline-store' @@ -78,7 +78,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Ctrl+Delete - Ripple delete selected items (delete + close gap) useCommandHotkey( - hotkeys.RIPPLE_DELETE, + 'RIPPLE_DELETE', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -100,7 +100,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Ctrl+Backspace - Ripple delete selected items (alternative) useCommandHotkey( - hotkeys.RIPPLE_DELETE_ALT, + 'RIPPLE_DELETE_ALT', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -122,7 +122,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+Arrow keys - nudge selected visual items by 1px useCommandHotkey( - hotkeys.NUDGE_LEFT, + 'NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedVisualItems(-1, 0) @@ -132,7 +132,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_RIGHT, + 'NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedVisualItems(1, 0) @@ -142,7 +142,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_UP, + 'NUDGE_UP', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -1) @@ -152,7 +152,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_DOWN, + 'NUDGE_DOWN', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 1) @@ -163,7 +163,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Cmd/Ctrl+Shift+Arrow keys - nudge selected visual items by 10px useCommandHotkey( - hotkeys.NUDGE_LEFT_LARGE, + 'NUDGE_LEFT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(-10, 0) @@ -173,7 +173,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_RIGHT_LARGE, + 'NUDGE_RIGHT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(10, 0) @@ -183,7 +183,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_UP_LARGE, + 'NUDGE_UP_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -10) @@ -193,7 +193,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.NUDGE_DOWN_LARGE, + 'NUDGE_DOWN_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 10) @@ -204,7 +204,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+J - Join selected clips useCommandHotkey( - hotkeys.JOIN_ITEMS, + 'JOIN_ITEMS', (event) => { if (selectedItemIds.length < 2) return @@ -224,7 +224,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.LINK_AUDIO_VIDEO, + 'LINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length < 2) return if (!canLinkSelection(items, selectedItemIds)) return @@ -237,7 +237,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.UNLINK_AUDIO_VIDEO, + 'UNLINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length === 0) return if (!selectedItemIds.some((id) => hasLinkedItems(items, id))) return @@ -250,7 +250,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) useCommandHotkey( - hotkeys.TOGGLE_LINKED_SELECTION, + 'TOGGLE_LINKED_SELECTION', (event) => { event.preventDefault() toggleLinkedSelectionEnabled() @@ -268,7 +268,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Alt+C - Split all items at gray playhead (or main playhead) useCommandHotkey( - hotkeys.SPLIT_AT_PLAYHEAD_ALT, + 'SPLIT_AT_PLAYHEAD_ALT', splitAtPlayhead, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [splitAtPlayhead], @@ -276,7 +276,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Editing: Shift+F - Insert freeze frame at playhead useCommandHotkey( - hotkeys.FREEZE_FRAME, + 'FREEZE_FRAME', (event) => { if (selectedItemIds.length !== 1) return const currentFrame = usePlaybackStore.getState().currentFrame @@ -299,7 +299,7 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { // Keyframes: Shift+A - Clear all keyframes for selected items (with confirmation) useCommandHotkey( - hotkeys.CLEAR_KEYFRAMES, + 'CLEAR_KEYFRAMES', (event) => { if (selectedItemIds.length === 0) return diff --git a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts index bdba661a3..eaba4bb38 100644 --- a/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-in-out-shortcuts.ts @@ -2,18 +2,14 @@ * Timeline in/out shortcuts: I, O, Shift+I/O, Alt+X. */ -import { - COMMAND_HOTKEYS as hotkeys, - useCommandHotkey, - useDerivedCommandHotkey, -} from '@/hooks/use-hotkey-registration' +import { useCommandHotkey, useDerivedCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' export function useInOutShortcuts() { useCommandHotkey( - hotkeys.MARK_IN, + 'MARK_IN', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -36,7 +32,7 @@ export function useInOutShortcuts() { ) useCommandHotkey( - hotkeys.MARK_OUT, + 'MARK_OUT', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -59,7 +55,7 @@ export function useInOutShortcuts() { ) useCommandHotkey( - hotkeys.CLEAR_IN_OUT, + 'CLEAR_IN_OUT', (event) => { event.preventDefault() useTimelineStore.getState().clearInOutPoints() diff --git a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts index f2adab901..e5073feb0 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -2,7 +2,7 @@ * Marker shortcuts: M (add), Shift+M (remove), [ ] (navigate). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useMarkersStore } from '../../stores/markers-store' import { useSelectionStore } from '@/shared/state/selection' @@ -15,7 +15,7 @@ export function useMarkerShortcuts() { // Markers: M - Add marker at playhead useCommandHotkey( - hotkeys.ADD_MARKER, + 'ADD_MARKER', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() @@ -27,7 +27,7 @@ export function useMarkerShortcuts() { // Markers: Shift+M - Remove selected marker useCommandHotkey( - hotkeys.REMOVE_MARKER, + 'REMOVE_MARKER', (event) => { event.preventDefault() const id = useSelectionStore.getState().selectedMarkerId @@ -42,7 +42,7 @@ export function useMarkerShortcuts() { // Markers: [ - Jump to previous marker useCommandHotkey( - hotkeys.PREVIOUS_MARKER, + 'PREVIOUS_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers @@ -66,7 +66,7 @@ export function useMarkerShortcuts() { // Markers: ] - Jump to next marker useCommandHotkey( - hotkeys.NEXT_MARKER, + 'NEXT_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers diff --git a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts index a69f188ab..7e7aa6ac1 100644 --- a/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-playback-shortcuts.ts @@ -3,7 +3,7 @@ */ import { useCallback } from 'react' -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { usePreviewBridgeStore } from '@/shared/state/preview-bridge' import { useItemsStore } from '../../stores/items-store' @@ -53,7 +53,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Playback: Space - Play/Pause useCommandHotkey( - hotkeys.PLAY_PAUSE, + 'PLAY_PAUSE', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -75,7 +75,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle forward advances through 1x, 2x, and 4x. Ignore browser key // repeat so one physical press produces one transport transition. useCommandHotkey( - hotkeys.SHUTTLE_FORWARD, + 'SHUTTLE_FORWARD', (event) => { if (event.repeat) return event.preventDefault() @@ -97,7 +97,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Shuttle reverse mirrors forward playback. Browser media stays on a paused visual // seek path for negative rates; the Clock still advances at display cadence. useCommandHotkey( - hotkeys.SHUTTLE_REVERSE, + 'SHUTTLE_REVERSE', (event) => { if (event.repeat) return event.preventDefault() @@ -119,7 +119,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Pause always owns its binding, including while already paused, so transport // routing cannot fall through to another command. useCommandHotkey( - hotkeys.SHUTTLE_PAUSE, + 'SHUTTLE_PAUSE', (event) => { if (event.repeat) return event.preventDefault() @@ -141,7 +141,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Arrow Left - Previous frame useCommandHotkey( - hotkeys.PREVIOUS_FRAME, + 'PREVIOUS_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -158,7 +158,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Arrow Right - Next frame useCommandHotkey( - hotkeys.NEXT_FRAME, + 'NEXT_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -175,7 +175,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Home - Go to start useCommandHotkey( - hotkeys.GO_TO_START, + 'GO_TO_START', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -191,7 +191,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: End - Go to end of timeline (last frame of last item) useCommandHotkey( - hotkeys.GO_TO_END, + 'GO_TO_END', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -212,7 +212,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Down - Jump to next snap point (clip edge or marker) useCommandHotkey( - hotkeys.NEXT_SNAP_POINT, + 'NEXT_SNAP_POINT', (event) => { event.preventDefault() const currentFrame = usePlaybackStore.getState().currentFrame @@ -227,7 +227,7 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { // Navigation: Up - Jump to previous snap point (clip edge or marker) useCommandHotkey( - hotkeys.PREVIOUS_SNAP_POINT, + 'PREVIOUS_SNAP_POINT', (event) => { event.preventDefault() const currentFrame = usePlaybackStore.getState().currentFrame diff --git a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts index 97af68c39..0adde7a38 100644 --- a/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-source-monitor-shortcuts.ts @@ -9,7 +9,7 @@ * source monitor is hovered/focused. */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { useEditorStore } from '@/shared/state/editor' import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/source-edit-actions' @@ -17,7 +17,7 @@ import { performInsertEdit, performOverwriteEdit } from '../../stores/actions/so export function useSourceMonitorShortcuts() { // Insert Edit: , (comma) — works globally when source monitor is open useCommandHotkey( - hotkeys.INSERT_EDIT, + 'INSERT_EDIT', (event) => { event.preventDefault() const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId @@ -30,7 +30,7 @@ export function useSourceMonitorShortcuts() { // Overwrite Edit: . (period) — works globally when source monitor is open useCommandHotkey( - hotkeys.OVERWRITE_EDIT, + 'OVERWRITE_EDIT', (event) => { event.preventDefault() const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId diff --git a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts index 30e833580..4e8ac7d94 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -2,7 +2,7 @@ * Tool shortcuts: V (Select), T (Trim Edit), C (Razor), Shift+C (Split at playhead), R (Rate Stretch). */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { usePlaybackStore } from '@/shared/state/playback' import { useTimelineStore } from '../../stores/timeline-store' import { useSelectionStore } from '@/shared/state/selection' @@ -16,7 +16,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: V - Selection Tool useCommandHotkey( - hotkeys.SELECTION_TOOL, + 'SELECTION_TOOL', (event) => { event.preventDefault() setActiveTool('select') @@ -27,7 +27,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: T - Toggle Trim Edit Tool useCommandHotkey( - hotkeys.TRIM_EDIT_TOOL, + 'TRIM_EDIT_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'trim-edit' ? 'select' : 'trim-edit') @@ -38,7 +38,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: C - Toggle Razor/Cut Mode useCommandHotkey( - hotkeys.RAZOR_TOOL, + 'RAZOR_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'razor' ? 'select' : 'razor') @@ -49,7 +49,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) useCommandHotkey( - hotkeys.SPLIT_AT_PLAYHEAD, + 'SPLIT_AT_PLAYHEAD', (event) => { event.preventDefault() const { previewFrame, previewItemId, currentFrame } = usePlaybackStore.getState() @@ -73,7 +73,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: R - Toggle Rate Stretch Tool useCommandHotkey( - hotkeys.RATE_STRETCH_TOOL, + 'RATE_STRETCH_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'rate-stretch' ? 'select' : 'rate-stretch') @@ -84,7 +84,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: Y - Toggle Slip Tool useCommandHotkey( - hotkeys.SLIP_TOOL, + 'SLIP_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'slip' ? 'select' : 'slip') @@ -95,7 +95,7 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { // Tool: U - Toggle Slide Tool useCommandHotkey( - hotkeys.SLIDE_TOOL, + 'SLIDE_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'slide' ? 'select' : 'slide') diff --git a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts index 379d5cfa9..1bd43ab02 100644 --- a/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-ui-shortcuts.ts @@ -2,7 +2,7 @@ * UI shortcuts: S (snap toggle), Cmd/Ctrl+=/- (zoom), \\ (zoom to fit), Shift+\\ or Cmd/Ctrl+0 (zoom to 100%), Undo/Redo. */ -import { COMMAND_HOTKEYS as hotkeys, useCommandHotkey } from '@/hooks/use-hotkey-registration' +import { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { useTimelineStore } from '../../stores/timeline-store' import { useZoomStore, getZoomTo100Handler } from '../../stores/zoom-store' import { usePlaybackStore } from '@/shared/state/playback' @@ -29,7 +29,7 @@ export function useUIShortcuts( // History: Cmd/Ctrl+Z - Undo useCommandHotkey( - hotkeys.UNDO, + 'UNDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().undo() @@ -47,7 +47,7 @@ export function useUIShortcuts( // History: Cmd/Ctrl+Shift+Z - Redo useCommandHotkey( - hotkeys.REDO, + 'REDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().redo() @@ -65,7 +65,7 @@ export function useUIShortcuts( // UI: S - Toggle Snap useCommandHotkey( - hotkeys.TOGGLE_SNAP, + 'TOGGLE_SNAP', (event) => { event.preventDefault() toggleSnap() @@ -76,7 +76,7 @@ export function useUIShortcuts( // UI: Shift+S - Toggle Canvas (gizmo) Snap — independent from timeline snap. useCommandHotkey( - hotkeys.TOGGLE_CANVAS_SNAP, + 'TOGGLE_CANVAS_SNAP', (event) => { event.preventDefault() const s = useSettingsStore.getState() @@ -90,7 +90,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+Equals - Zoom in useCommandHotkey( - hotkeys.ZOOM_IN, + 'ZOOM_IN', (event) => { event.preventDefault() zoomIn() @@ -101,7 +101,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+Minus - Zoom out useCommandHotkey( - hotkeys.ZOOM_OUT, + 'ZOOM_OUT', (event) => { event.preventDefault() zoomOut() @@ -112,7 +112,7 @@ export function useUIShortcuts( // Zoom: Backslash - Zoom to Fit useCommandHotkey( - hotkeys.ZOOM_TO_FIT, + 'ZOOM_TO_FIT', (event) => { event.preventDefault() if (callbacks.onZoomToFit) { @@ -144,7 +144,7 @@ export function useUIShortcuts( // Zoom: Shift+Backslash - Zoom to 100% centered on cursor (or playhead if cursor not on timeline) useCommandHotkey( - hotkeys.ZOOM_TO_100, + 'ZOOM_TO_100', (event) => { event.preventDefault() const { currentFrame, previewFrame } = usePlaybackStore.getState() @@ -161,7 +161,7 @@ export function useUIShortcuts( // Zoom: Cmd/Ctrl+0 - Reset timeline zoom to 100% useCommandHotkey( - hotkeys.ZOOM_TO_100_ALT, + 'ZOOM_TO_100_ALT', (event) => { event.preventDefault() const { currentFrame, previewFrame } = usePlaybackStore.getState() diff --git a/src/hooks/use-hotkey-registration.test.ts b/src/hooks/use-hotkey-registration.test.ts new file mode 100644 index 000000000..cff02a545 --- /dev/null +++ b/src/hooks/use-hotkey-registration.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vite-plus/test' +import * as registration from './use-hotkey-registration' + +vi.mock('react-hotkeys-hook', () => ({ useHotkeys: vi.fn() })) +vi.mock('@/config/hotkeys', () => ({ HOTKEY_OPTIONS: {} })) +vi.mock('./use-runtime-hotkey-binding', () => ({ useRuntimeHotkeyBinding: vi.fn() })) + +describe('hotkey registration adapter surface', () => { + it('loads with a partial hotkey config mock and exposes no command proxy object API', () => { + expect(registration).not.toHaveProperty('COMMAND_HOTKEYS') + }) + + it('rejects invalid command literals at typecheck', () => { + // @ts-expect-error invalid command literals cannot enter the adapter API + const invalidCommand: Parameters[0] = 'NOT_A_COMMAND' + expect(invalidCommand).toBe('NOT_A_COMMAND') + }) +}) diff --git a/src/hooks/use-hotkey-registration.ts b/src/hooks/use-hotkey-registration.ts index 3d6dc2f4c..c57aeabff 100644 --- a/src/hooks/use-hotkey-registration.ts +++ b/src/hooks/use-hotkey-registration.ts @@ -1,24 +1,11 @@ import type { DependencyList } from 'react' import { useHotkeys, type HotkeyCallback, type Options } from 'react-hotkeys-hook' -import { HOTKEYS, HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' +import { HOTKEY_OPTIONS, type HotkeyKey } from '@/config/hotkeys' import { useRuntimeHotkeyBinding } from './use-runtime-hotkey-binding' type HotkeyOptionsOrDependencies = Options | DependencyList export type DerivedHotkeyCommand = 'MARK_IN' | 'MARK_OUT' -/** Command identifiers only; values never contain display or persisted bindings. */ -export const COMMAND_HOTKEYS = new Proxy({} as Record, { - get: (_target, command) => (typeof command === 'string' ? (command as HotkeyKey) : undefined), - ownKeys: () => Object.keys(HOTKEYS), - getOwnPropertyDescriptor: (_target, command) => - typeof command === 'string' && Object.hasOwn(HOTKEYS, command) - ? { configurable: true, enumerable: true, value: command, writable: false } - : undefined, - set: () => false, - defineProperty: () => false, - deleteProperty: () => false, -}) as Readonly> - const LOCAL_HOTKEY_BINDINGS = { DOPESHEET_DELETE: 'delete,backspace', DOPESHEET_NUDGE_LEFT: 'left', From 0eda4ba74aaf230119c783ecd710379e518a7fae Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 06:25:23 -0700 Subject: [PATCH 17/21] fix(qa): resolve hotkey imports lexically --- scripts/runtime-hotkey-import-boundary.mjs | 285 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 227 ++++++++++++++ 2 files changed, 465 insertions(+), 47 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index a043ae967..d7db6f0b3 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -20,7 +20,6 @@ import { isStringLiteral, isTemplateExpression, isTypeAssertion, - isVariableStatement, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' @@ -33,74 +32,280 @@ const CONSTANT_STRING_WRAPPER_CHECKS = [ isTypeAssertion, ] -function evaluateTemplateString(expression, constantBindings, resolving) { +const FUNCTION_SCOPE_KINDS = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.FunctionExpression, + SyntaxKind.ArrowFunction, + SyntaxKind.MethodDeclaration, + SyntaxKind.Constructor, + SyntaxKind.GetAccessor, + SyntaxKind.SetAccessor, +]) + +const NAMED_FUNCTION_SCOPE_KINDS = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.FunctionExpression, +]) + +const CLASS_SCOPE_KINDS = new Set([SyntaxKind.ClassDeclaration, SyntaxKind.ClassExpression]) + +const LOOP_SCOPE_KINDS = new Set([ + SyntaxKind.ForStatement, + SyntaxKind.ForInStatement, + SyntaxKind.ForOfStatement, +]) + +const BLOCK_SCOPE_KINDS = new Set([ + SyntaxKind.Block, + SyntaxKind.ClassStaticBlockDeclaration, + SyntaxKind.ModuleBlock, +]) + +const BLOCK_VAR_SCOPE_KINDS = new Set([ + SyntaxKind.ClassStaticBlockDeclaration, + SyntaxKind.ModuleBlock, +]) + +const BARRIER_DECLARATION_KINDS = new Set([ + SyntaxKind.EnumDeclaration, + SyntaxKind.ModuleDeclaration, +]) + +function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { + return { parent, kind, isVarScope, isConstantBoundary, bindings: new Map() } +} + +function declareBinding(scope, name, binding) { + if (scope.bindings.has(name)) { + scope.bindings.set(name, { kind: 'barrier' }) + return + } + scope.bindings.set(name, binding) +} + +function bindingNames(name) { + if (isIdentifier(name)) return [name.text] + if (name.kind !== SyntaxKind.ObjectBindingPattern && name.kind !== SyntaxKind.ArrayBindingPattern) { + return [] + } + return name.elements.flatMap((element) => (element.name ? bindingNames(element.name) : [])) +} + +function declareBarrier(scope, name) { + for (const identifier of bindingNames(name)) { + declareBinding(scope, identifier, { kind: 'barrier' }) + } +} + +function nearestVarScope(scope) { + let current = scope + while (current.parent && !current.isVarScope) current = current.parent + return current +} + +function declareVariableList(declarationList, scope) { + const isConst = Boolean(declarationList.flags & NodeFlags.Const) + const isBlockScoped = Boolean(declarationList.flags & NodeFlags.BlockScoped) + const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) + // Rolldown keeps loop-header identifier imports dynamic even when the + // header declares a const literal, so those bindings remain barriers. + const isResolvableConst = isConst && declarationScope.kind !== 'loop' + + for (const declaration of declarationList.declarations) { + if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { + declareBinding(declarationScope, declaration.name.text, { + kind: 'constant', + initializer: declaration.initializer, + scope: declarationScope, + }) + continue + } + declareBarrier(declarationScope, declaration.name) + } +} + +function declareImportBindings(node, scope) { + if (isImportEqualsDeclaration(node)) { + declareBarrier(scope, node.name) + return + } + if (!isImportDeclaration(node) || !node.importClause) return + + const { name, namedBindings } = node.importClause + if (name) declareBarrier(scope, name) + if (!namedBindings) return + if (namedBindings.name) { + declareBarrier(scope, namedBindings.name) + return + } + for (const element of namedBindings.elements) declareBarrier(scope, element.name) +} + +function createFunctionLexicalScope(node, currentScope) { + if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { + declareBarrier(currentScope, node.name) + } + + const functionScope = createScope(currentScope, 'function', true, true) + if (NAMED_FUNCTION_SCOPE_KINDS.has(node.kind) && node.name) { + declareBarrier(functionScope, node.name) + } + for (const parameter of node.parameters ?? []) declareBarrier(functionScope, parameter.name) + return functionScope +} + +function createClassLexicalScope(node, currentScope) { + if (node.kind === SyntaxKind.ClassDeclaration && node.name) { + declareBarrier(currentScope, node.name) + } + + const classScope = createScope(currentScope, 'class', false, true) + if (node.name) declareBarrier(classScope, node.name) + return classScope +} + +function createChildLexicalScope(node, currentScope) { + if (FUNCTION_SCOPE_KINDS.has(node.kind)) { + return createFunctionLexicalScope(node, currentScope) + } + if (CLASS_SCOPE_KINDS.has(node.kind)) { + return createClassLexicalScope(node, currentScope) + } + if (node.kind === SyntaxKind.CatchClause) { + const catchScope = createScope(currentScope, 'catch') + if (node.variableDeclaration) declareBarrier(catchScope, node.variableDeclaration.name) + return catchScope + } + if (LOOP_SCOPE_KINDS.has(node.kind)) return createScope(currentScope, 'loop') + if (node.kind === SyntaxKind.SwitchStatement) return createScope(currentScope, 'block') + if (!BLOCK_SCOPE_KINDS.has(node.kind)) return undefined + + return createScope( + currentScope, + 'block', + BLOCK_VAR_SCOPE_KINDS.has(node.kind), + node.kind === SyntaxKind.ModuleBlock, + ) +} + +function predeclareNodeBindings(node, currentScope) { + if (node.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node, currentScope) + return + } + if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { + declareImportBindings(node, currentScope) + return + } + if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { + declareBarrier(currentScope, node.name) + } +} + +function buildLexicalScopes(sourceFile) { + const sourceScope = createScope(undefined, 'source', true, true) + const nodeScopes = new WeakMap() + + function visit(node, currentScope) { + nodeScopes.set(node, currentScope) + const childScope = createChildLexicalScope(node, currentScope) + if (!childScope) predeclareNodeBindings(node, currentScope) + node.forEachChild((child) => visit(child, childScope ?? currentScope)) + } + + visit(sourceFile, sourceScope) + return { nodeScopes, sourceScope } +} + +function findBinding(scope, name) { + let current = scope + while (current) { + const binding = current.bindings.get(name) + if (binding) return binding + // Rolldown folds through lexical blocks, but not through captured + // function, class, or namespace environments. + if (current.isConstantBoundary) return undefined + current = current.parent + } + return undefined +} + +function evaluateTemplateString(expression, scope, resolving, referenceScope) { let value = expression.head.text for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString(span.expression, constantBindings, resolving) + const interpolation = evaluateConstantString( + span.expression, + scope, + resolving, + referenceScope, + ) if (interpolation === undefined) return undefined value += interpolation + span.literal.text } return value } -function evaluateConcatenatedString(expression, constantBindings, resolving) { +function evaluateConcatenatedString(expression, scope, resolving, referenceScope) { if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, constantBindings, resolving) - const right = evaluateConstantString(expression.right, constantBindings, resolving) + const left = evaluateConstantString(expression.left, scope, resolving, referenceScope) + const right = evaluateConstantString(expression.right, scope, resolving, referenceScope) return left === undefined || right === undefined ? undefined : left + right } -function evaluateConstantBinding(expression, constantBindings, resolving) { - if (!constantBindings.has(expression.text) || resolving.has(expression.text)) return undefined - const nextResolving = new Set(resolving).add(expression.text) - return evaluateConstantString( - constantBindings.get(expression.text), - constantBindings, - nextResolving, - ) +function evaluateConstantBinding(expression, scope, resolving, referenceScope) { + const binding = findBinding(scope, expression.text) + // Initializers use their declaration environment, but Rolldown only folds + // an alias when that referenced binding is still the one visible at use. + if ( + !binding || + binding.kind !== 'constant' || + resolving.has(binding) || + findBinding(referenceScope, expression.text) !== binding + ) { + return undefined + } + const nextResolving = new Set(resolving).add(binding) + return evaluateConstantString(binding.initializer, binding.scope, nextResolving, referenceScope) } -function evaluateConstantString(expression, constantBindings, resolving = new Set()) { +function evaluateConstantString(expression, scope, resolving = new Set(), referenceScope = scope) { if (!expression) return undefined if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { return expression.text } if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString(expression.expression, constantBindings, resolving) + return evaluateConstantString(expression.expression, scope, resolving, referenceScope) } if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, constantBindings, resolving) + return evaluateTemplateString(expression, scope, resolving, referenceScope) } if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, constantBindings, resolving) + return evaluateConcatenatedString(expression, scope, resolving, referenceScope) } if (isIdentifier(expression)) { - return evaluateConstantBinding(expression, constantBindings, resolving) + return evaluateConstantBinding(expression, scope, resolving, referenceScope) } return undefined } -function isReactHotkeysSource(source, constantBindings) { - return evaluateConstantString(source, constantBindings) === REACT_HOTKEYS_HOOK_MODULE +function isReactHotkeysSource(source, scope) { + return evaluateConstantString(source, scope) === REACT_HOTKEYS_HOOK_MODULE } -function isStaticReactHotkeysImport(node, constantBindings) { +function isStaticReactHotkeysImport(node, scope) { return ( (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier, constantBindings) + isReactHotkeysSource(node.moduleSpecifier, scope) ) } -function isTypeScriptReactHotkeysImport(node, constantBindings) { +function isTypeScriptReactHotkeysImport(node, scope) { if (!isImportEqualsDeclaration(node)) return false const reference = node.moduleReference - return ( - isExternalModuleReference(reference) && - isReactHotkeysSource(reference.expression, constantBindings) - ) + return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression, scope) } -function isReactHotkeysCallImport(node, constantBindings) { +function isReactHotkeysCallImport(node, scope) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node const isRequire = isIdentifier(expression) && expression.text === 'require' @@ -108,7 +313,7 @@ function isReactHotkeysCallImport(node, constantBindings) { return ( (isRequire || isDynamicImport) && args.length === 1 && - isReactHotkeysSource(args[0], constantBindings) + isReactHotkeysSource(args[0], scope) ) } @@ -123,21 +328,6 @@ function walkAst(node, onNode) { node.forEachChild((child) => walkAst(child, onNode)) } -function topLevelConstantBindings(sourceFile) { - const bindings = new Map() - for (const statement of sourceFile.statements) { - if (!isVariableStatement(statement)) continue - const declarationList = statement.declarationList - if (!(declarationList.flags & NodeFlags.Const)) continue - for (const declaration of declarationList.declarations) { - if (isIdentifier(declaration.name) && declaration.initializer) { - bindings.set(declaration.name.text, declaration.initializer) - } - } - } - return bindings -} - export function findReactHotkeysHookImportViolations( sources, allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, @@ -170,7 +360,7 @@ export function findReactHotkeysHookImportViolations( for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) - const constantBindings = topLevelConstantBindings(sourceFile) + const { nodeScopes, sourceScope } = buildLexicalScopes(sourceFile) function record(node) { if (path === allowedPath) return @@ -187,7 +377,8 @@ export function findReactHotkeysHookImportViolations( } walkAst(sourceFile, (node) => { - if (IMPORT_NODE_CHECKS.some((check) => check(node, constantBindings))) record(node) + const scope = nodeScopes.get(node) ?? sourceScope + if (IMPORT_NODE_CHECKS.some((check) => check(node, scope))) record(node) }) } } finally { diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 7ba875754..d189965c8 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -10,6 +10,133 @@ import { const BOUNDARY_SCRIPT = join(process.cwd(), 'scripts/runtime-hotkey-import-boundary.mjs') +const ROLLDOWN_PARITY_CASES = [ + { + name: 'function-local const', + source: "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'shadowed parameter', + source: + "const pkg = 'react-hotkeys-hook'; export function load(pkg: string) { return import(pkg) }", + resolves: false, + }, + { + name: 'nested block const', + source: + "export function load() { if (true) { const pkg = 'react-hotkeys-hook'; return import(pkg) } }", + resolves: true, + }, + { + name: 'catch destructuring shadow', + source: + "const pkg = 'react-hotkeys-hook'; export function load() { try { throw { pkg: 'dynamic' } } catch ({ pkg }) { return import(pkg) } }", + resolves: false, + }, + { + name: 'const alias chain', + source: + "export function load() { const prefix = 'react-'; const suffix = 'hotkeys-hook'; const pkg = prefix + suffix; return import(pkg) }", + resolves: true, + }, + { + name: 'nested block alias chain', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { const alias = pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'shadowed alias initializer reference', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'declaration environment alias', + source: + "declare function moduleName(): string; export function load() { const pkg = moduleName(); const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }", + resolves: false, + }, + { + name: 'outer function boundary const', + source: "const pkg = 'react-hotkeys-hook'; export function load() { return import(pkg) }", + resolves: false, + }, + { + name: 'outer class boundary const', + source: "const pkg = 'react-hotkeys-hook'; export class Loader { static load = import(pkg) }", + resolves: false, + }, + { + name: 'loop-header const', + source: + "export function load() { for (const pkg = 'react-hotkeys-hook'; ;) return import(pkg) }", + resolves: false, + }, + { + name: 'const alias cycle', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = pkg; return import(pkg) } }", + resolves: false, + }, + { + name: 'unknown const initializer', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = moduleName(); return import(pkg) } }", + resolves: false, + }, +] as const + +const ROLLDOWN_PARITY_SCRIPT = ` + import { rolldown, VERSION } from 'rolldown' + + let input = '' + for await (const chunk of process.stdin) input += chunk + const sources = JSON.parse(input) + const resolutions = [] + + for (const [index, source] of sources.entries()) { + const entry = \`virtual:runtime-hotkey-boundary-\${index}.ts\` + const bundle = await rolldown({ + input: entry, + external: ['react-hotkeys-hook'], + plugins: [{ + name: 'runtime-hotkey-boundary-memory-fixture', + resolveId(id) { if (id === entry) return id }, + load(id) { if (id === entry) return source }, + }], + }) + + try { + const generated = await bundle.generate({ format: 'es' }) + const chunk = generated.output.find((output) => output.type === 'chunk') + if (!chunk) throw new Error('Rolldown did not generate a JavaScript chunk') + resolutions.push(/import\\(["']react-hotkeys-hook["']\\)/.test(chunk.code)) + } finally { + await bundle.close() + } + } + + process.stdout.write(JSON.stringify({ version: VERSION, resolutions })) +` + +function runRolldownParityFixtures(sources: readonly string[]) { + const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', ROLLDOWN_PARITY_SCRIPT], + { + cwd: process.cwd(), + encoding: 'utf8', + input: JSON.stringify(sources), + }, + ) + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || 'Rolldown parity process failed') + } + return JSON.parse(result.stdout) as { version: string; resolutions: boolean[] } +} + describe('runtime hotkey registration coverage', () => { it('checks the full source tree in a standalone Node process', () => { const startedAt = performance.now() @@ -63,6 +190,106 @@ describe('runtime hotkey registration coverage', () => { ) }) + it('detects a function-local lexical const resolved by Rolldown', () => { + const localConst = + "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/function-local.ts', source: localConst }, + ]), + ).toEqual([expect.objectContaining({ path: 'src/features/function-local.ts' })]) + }) + + it('does not fall through a shadowed lexical parameter Rolldown keeps dynamic', () => { + const shadowedParameter = + "const pkg = 'react-hotkeys-hook'; export function load(pkg: string) { return import(pkg) }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/shadowed-parameter.ts', source: shadowedParameter }, + ]), + ).toEqual([]) + }) + + it('matches Rolldown constant folding across lexical scopes and shadow barriers', () => { + const parity = runRolldownParityFixtures(ROLLDOWN_PARITY_CASES.map(({ source }) => source)) + expect(parity.version).toBe('1.1.5') + expect(parity.resolutions).toHaveLength(ROLLDOWN_PARITY_CASES.length) + + for (const [index, fixture] of ROLLDOWN_PARITY_CASES.entries()) { + const checkerResolves = + findReactHotkeysHookImportViolations([ + { path: `src/features/${fixture.name.replaceAll(' ', '-')}.ts`, source: fixture.source }, + ]).length === 1 + const rolldownResolves = parity.resolutions[index] + + expect(rolldownResolves, `${fixture.name}: Rolldown fixture expectation`).toBe( + fixture.resolves, + ) + expect(checkerResolves, `${fixture.name}: checker/Rolldown parity`).toBe(rolldownResolves) + } + }) + + it('predeclares every lexical shadow barrier before resolving identifier imports', () => { + const fixtures: Array<[string, string]> = [ + [ + 'let-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); let pkg }", + ], + [ + 'var-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); var pkg }", + ], + [ + 'nested-var-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { { import(pkg) } if (true) { var pkg } }", + ], + [ + 'destructuring-after-import', + "const pkg = 'react-hotkeys-hook'; export function load(value: { pkg: string }) { import(pkg); const { pkg } = value }", + ], + ['import-binding', "import pkg from './runtime-name'; export const load = () => import(pkg)"], + [ + 'import-equals-binding', + "const pkg = 'react-hotkeys-hook'; declare namespace Runtime { const pkg: string } namespace Loader { import pkg = Runtime.pkg; export const load = () => import(pkg) }", + ], + [ + 'class-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); class pkg {} }", + ], + [ + 'function-after-import', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); function pkg() {} }", + ], + [ + 'const-without-initializer', + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); const pkg: string }", + ], + [ + 'loop-destructuring', + "const pkg = 'react-hotkeys-hook'; export function load(values: Array<{ pkg: string }>) { for (const { pkg } of values) import(pkg) }", + ], + ] + + expect( + findReactHotkeysHookImportViolations( + fixtures.map(([name, source]) => ({ path: `src/features/${name}.ts`, source })), + ), + ).toEqual([]) + }) + + it("evaluates a const initializer in its declaration's lexical environment", () => { + const source = + "declare function moduleName(): string; export function load() { const pkg = moduleName(); const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }" + + expect( + findReactHotkeysHookImportViolations([ + { path: 'src/features/declaration-environment.ts', source }, + ]), + ).toEqual([]) + }) + it('does not trap text or non-constant module expressions', () => { const source = ` // import('react-hotkeys-hook') From 3a698020974e8d38ee047aed92e669c57fca867d Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 07:43:42 -0700 Subject: [PATCH 18/21] fix(qa): match Rolldown lexical import folding --- scripts/runtime-hotkey-import-boundary.mjs | 174 ++++++++++++++---- ...ntime-hotkey-registration-coverage.test.ts | 154 +++++++++++++++- 2 files changed, 296 insertions(+), 32 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index d7db6f0b3..66d424464 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -53,6 +53,8 @@ const LOOP_SCOPE_KINDS = new Set([ SyntaxKind.ForStatement, SyntaxKind.ForInStatement, SyntaxKind.ForOfStatement, + SyntaxKind.WhileStatement, + SyntaxKind.DoStatement, ]) const BLOCK_SCOPE_KINDS = new Set([ @@ -117,6 +119,7 @@ function declareVariableList(declarationList, scope) { kind: 'constant', initializer: declaration.initializer, scope: declarationScope, + availableAfter: declaration.end, }) continue } @@ -172,11 +175,10 @@ function createChildLexicalScope(node, currentScope) { return createClassLexicalScope(node, currentScope) } if (node.kind === SyntaxKind.CatchClause) { - const catchScope = createScope(currentScope, 'catch') + const catchScope = createScope(currentScope, 'catch', false, true) if (node.variableDeclaration) declareBarrier(catchScope, node.variableDeclaration.name) return catchScope } - if (LOOP_SCOPE_KINDS.has(node.kind)) return createScope(currentScope, 'loop') if (node.kind === SyntaxKind.SwitchStatement) return createScope(currentScope, 'block') if (!BLOCK_SCOPE_KINDS.has(node.kind)) return undefined @@ -206,8 +208,55 @@ function buildLexicalScopes(sourceFile) { const sourceScope = createScope(undefined, 'source', true, true) const nodeScopes = new WeakMap() + function visitLoopHeader(node, currentScope) { + if (!node) return + nodeScopes.set(node, currentScope) + node.forEachChild((child) => visit(child, currentScope)) + } + + function visitLoop(node, currentScope) { + const loopScope = createScope(currentScope, 'loop', false, true) + + if (node.kind === SyntaxKind.ForStatement) { + // Rolldown folds outer constants in a classic-for initializer, then + // stops carrying them through the condition, update, and body. + if (node.initializer?.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node.initializer, loopScope) + visitLoopHeader(node.initializer, currentScope) + } else { + visit(node.initializer, currentScope) + } + visit(node.condition, loopScope) + visit(node.incrementor, loopScope) + visit(node.statement, loopScope) + return + } + + if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { + if (node.initializer.kind === SyntaxKind.VariableDeclarationList) { + declareVariableList(node.initializer, loopScope) + visitLoopHeader(node.initializer, currentScope) + } else { + visit(node.initializer, currentScope) + } + // The collection expression behaves like an initializer; the repeated + // body is the constant-resolution boundary. + visit(node.expression, currentScope) + visit(node.statement, loopScope) + return + } + + visit(node.expression, loopScope) + visit(node.statement, loopScope) + } + function visit(node, currentScope) { + if (!node) return nodeScopes.set(node, currentScope) + if (LOOP_SCOPE_KINDS.has(node.kind)) { + visitLoop(node, currentScope) + return + } const childScope = createChildLexicalScope(node, currentScope) if (!childScope) predeclareNodeBindings(node, currentScope) node.forEachChild((child) => visit(child, childScope ?? currentScope)) @@ -222,74 +271,137 @@ function findBinding(scope, name) { while (current) { const binding = current.bindings.get(name) if (binding) return binding - // Rolldown folds through lexical blocks, but not through captured - // function, class, or namespace environments. + // Rolldown folds through ordinary lexical blocks, but not through + // captured, catch, repeated-loop, or namespace environments. if (current.isConstantBoundary) return undefined current = current.parent } return undefined } -function evaluateTemplateString(expression, scope, resolving, referenceScope) { +function isBindingAvailable(binding, referencePosition) { + return binding.availableAfter === undefined || referencePosition >= binding.availableAfter +} + +function evaluateTemplateString(expression, scope, resolving) { let value = expression.head.text + const dependencies = [] for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString( - span.expression, - scope, - resolving, - referenceScope, - ) - if (interpolation === undefined) return undefined - value += interpolation + span.literal.text + const interpolation = evaluateConstantString(span.expression, scope, resolving) + if (!interpolation) return undefined + value += interpolation.value + span.literal.text + dependencies.push(...interpolation.dependencies) } - return value + return { value, dependencies } } -function evaluateConcatenatedString(expression, scope, resolving, referenceScope) { +function evaluateConcatenatedString(expression, scope, resolving) { if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, scope, resolving, referenceScope) - const right = evaluateConstantString(expression.right, scope, resolving, referenceScope) - return left === undefined || right === undefined ? undefined : left + right + const left = evaluateConstantString(expression.left, scope, resolving) + const right = evaluateConstantString(expression.right, scope, resolving) + if (!left || !right) return undefined + return { + value: left.value + right.value, + dependencies: [...left.dependencies, ...right.dependencies], + } +} + +function evaluateConstantBindingValue(binding, resolving) { + // Bindings are stable identities, so aliases keep the declaration-time + // environment even when the same name is shadowed at a later use site. + if (binding.cachedValue !== undefined) return binding.cachedValue + if (resolving.has(binding)) return undefined + + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantString(binding.initializer, binding.scope, nextResolving) + binding.cachedValue = result ?? null + return result } -function evaluateConstantBinding(expression, scope, resolving, referenceScope) { +function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving) { + const visibleBinding = findBinding(referenceScope, dependency.name) + if ( + !visibleBinding || + visibleBinding === dependency.binding || + !isBindingAvailable(visibleBinding, referencePosition) + ) { + return true + } + if (visibleBinding.kind !== 'constant') return false + + // A same-value shadow is eliminated by Rolldown and does not prevent the + // captured alias from becoming a literal import. An unknown shadow does. + const visibleValue = evaluateConstantBindingValue(visibleBinding, resolving) + return visibleValue?.value === dependency.value +} + +function evaluateConstantBinding(expression, scope, resolving, referenceScope, referencePosition) { const binding = findBinding(scope, expression.text) - // Initializers use their declaration environment, but Rolldown only folds - // an alias when that referenced binding is still the one visible at use. if ( !binding || binding.kind !== 'constant' || resolving.has(binding) || - findBinding(referenceScope, expression.text) !== binding + !isBindingAvailable(binding, expression.getStart()) ) { return undefined } - const nextResolving = new Set(resolving).add(binding) - return evaluateConstantString(binding.initializer, binding.scope, nextResolving, referenceScope) + + const value = evaluateConstantBindingValue(binding, resolving) + if (!value) return undefined + const dependencies = [ + { name: expression.text, binding, value: value.value }, + ...value.dependencies, + ] + if ( + !dependencies.every((dependency) => + dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving), + ) + ) { + return undefined + } + return { value: value.value, dependencies } } -function evaluateConstantString(expression, scope, resolving = new Set(), referenceScope = scope) { +function evaluateConstantString( + expression, + scope, + resolving = new Set(), + referenceScope = scope, + referencePosition = expression?.getStart() ?? 0, +) { if (!expression) return undefined if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { - return expression.text + return { value: expression.text, dependencies: [] } } if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString(expression.expression, scope, resolving, referenceScope) + return evaluateConstantString( + expression.expression, + scope, + resolving, + referenceScope, + referencePosition, + ) } if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, scope, resolving, referenceScope) + return evaluateTemplateString(expression, scope, resolving) } if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, scope, resolving, referenceScope) + return evaluateConcatenatedString(expression, scope, resolving) } if (isIdentifier(expression)) { - return evaluateConstantBinding(expression, scope, resolving, referenceScope) + return evaluateConstantBinding( + expression, + scope, + resolving, + referenceScope, + referencePosition, + ) } return undefined } function isReactHotkeysSource(source, scope) { - return evaluateConstantString(source, scope) === REACT_HOTKEYS_HOOK_MODULE + return evaluateConstantString(source, scope)?.value === REACT_HOTKEYS_HOOK_MODULE } function isStaticReactHotkeysImport(node, scope) { diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index d189965c8..657dbdb76 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -46,6 +46,12 @@ const ROLLDOWN_PARITY_CASES = [ "export function load() { const pkg = 'react-hotkeys-hook'; { const alias = pkg; return import(alias) } }", resolves: true, }, + { + name: 'same-value nested shadow alias', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'react-hotkeys-hook'; return import(alias) } }", + resolves: true, + }, { name: 'shadowed alias initializer reference', source: @@ -74,6 +80,148 @@ const ROLLDOWN_PARITY_CASES = [ "export function load() { for (const pkg = 'react-hotkeys-hook'; ;) return import(pkg) }", resolves: false, }, + { + name: 'enclosing catch const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; try { throw 1 } catch { return import(pkg) } }", + resolves: false, + }, + { + name: 'catch-local const', + source: + "export function load() { try { throw 1 } catch { const pkg = 'react-hotkeys-hook'; return import(pkg) } }", + resolves: true, + }, + { + name: 'catch-local alias chain', + source: + "export function load() { try { throw 1 } catch { const pkg = 'react-hotkeys-hook'; const alias = pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'catch-local alias cycle', + source: + 'export function load() { try { throw 1 } catch { const pkg = pkg; return import(pkg) } }', + resolves: false, + }, + { + name: 'classic for initializer outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (import(pkg); ;) break }", + resolves: true, + }, + { + name: 'classic for condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (; import(pkg); ) break }", + resolves: false, + }, + { + name: 'classic for update outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (; ; import(pkg)) break }", + resolves: false, + }, + { + name: 'classic for body outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (;;) { import(pkg); break } }", + resolves: false, + }, + { + name: 'classic for body local const', + source: + "export function load() { for (;;) { const pkg = 'react-hotkeys-hook'; import(pkg); break } }", + resolves: true, + }, + { + name: 'classic for body local alias cycle', + source: 'export function load() { for (;;) { const pkg = pkg; import(pkg); break } }', + resolves: false, + }, + { + name: 'for-in expression outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const key in import(pkg)) void key }", + resolves: true, + }, + { + name: 'for-in body outer const', + source: + "export function load(values: object) { const pkg = 'react-hotkeys-hook'; for (const key in values) import(pkg) }", + resolves: false, + }, + { + name: 'for-in body local const', + source: + "export function load(values: object) { for (const key in values) { const pkg = 'react-hotkeys-hook'; import(pkg) } }", + resolves: true, + }, + { + name: 'for-of expression outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const value of import(pkg)) void value }", + resolves: true, + }, + { + name: 'for-of body outer const', + source: + "export function load(values: unknown[]) { const pkg = 'react-hotkeys-hook'; for (const value of values) import(pkg) }", + resolves: false, + }, + { + name: 'for-of body local const', + source: + "export function load(values: unknown[]) { for (const value of values) { const pkg = 'react-hotkeys-hook'; import(pkg) } }", + resolves: true, + }, + { + name: 'while condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; while (import(pkg)) break }", + resolves: false, + }, + { + name: 'while body outer const', + source: + "export function load(active: boolean) { const pkg = 'react-hotkeys-hook'; while (active) { import(pkg); break } }", + resolves: false, + }, + { + name: 'while body local const', + source: + "export function load(active: boolean) { while (active) { const pkg = 'react-hotkeys-hook'; import(pkg); break } }", + resolves: true, + }, + { + name: 'do-while condition outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; do {} while (import(pkg)) }", + resolves: false, + }, + { + name: 'do-while body outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; do { import(pkg) } while (false) }", + resolves: false, + }, + { + name: 'do-while body local const', + source: + "export function load() { do { const pkg = 'react-hotkeys-hook'; import(pkg) } while (false) }", + resolves: true, + }, + { + name: 'direct const temporal dead zone', + source: "export function load() { import(pkg); const pkg = 'react-hotkeys-hook' }", + resolves: false, + }, + { + name: 'alias initializer temporal dead zone', + source: + "export function load() { const alias = pkg; const pkg = 'react-hotkeys-hook'; import(alias) }", + resolves: false, + }, { name: 'const alias cycle', source: @@ -217,6 +365,7 @@ describe('runtime hotkey registration coverage', () => { expect(parity.version).toBe('1.1.5') expect(parity.resolutions).toHaveLength(ROLLDOWN_PARITY_CASES.length) + const mismatches: string[] = [] for (const [index, fixture] of ROLLDOWN_PARITY_CASES.entries()) { const checkerResolves = findReactHotkeysHookImportViolations([ @@ -227,8 +376,11 @@ describe('runtime hotkey registration coverage', () => { expect(rolldownResolves, `${fixture.name}: Rolldown fixture expectation`).toBe( fixture.resolves, ) - expect(checkerResolves, `${fixture.name}: checker/Rolldown parity`).toBe(rolldownResolves) + if (checkerResolves !== rolldownResolves) { + mismatches.push(`${fixture.name}: checker=${checkerResolves}, Rolldown=${rolldownResolves}`) + } } + expect(mismatches).toEqual([]) }) it('predeclares every lexical shadow barrier before resolving identifier imports', () => { From 62e6c1d6ecc526209520041fc78430e798e5fb8c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 08:31:53 -0700 Subject: [PATCH 19/21] fix(shortcuts): repair runtime hotkey boundary parity --- scripts/runtime-hotkey-import-boundary.mjs | 787 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 382 ++++++++- 2 files changed, 1071 insertions(+), 98 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 66d424464..85c92b679 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -6,31 +6,36 @@ import { createVirtualFileSystem } from 'typescript/unstable/fs' import { SyntaxKind, NodeFlags, - isAsExpression, isBinaryExpression, isCallExpression, + isElementAccessExpression, + isEnumDeclaration, isExportDeclaration, isExternalModuleReference, isIdentifier, isImportDeclaration, isImportEqualsDeclaration, isNoSubstitutionTemplateLiteral, - isParenthesizedExpression, - isSatisfiesExpression, + isNumericLiteral, + isPostfixUnaryExpression, + isPrefixUnaryExpression, + isPropertyAccessExpression, isStringLiteral, - isTemplateExpression, - isTypeAssertion, } from 'typescript/unstable/ast' const REACT_HOTKEYS_HOOK_MODULE = 'react-hotkeys-hook' export const RUNTIME_HOTKEY_ADAPTER_PATH = 'src/hooks/use-hotkey-registration.ts' -const CONSTANT_STRING_WRAPPER_CHECKS = [ - isParenthesizedExpression, - isAsExpression, - isSatisfiesExpression, - isTypeAssertion, -] +const MAX_CONSTANT_EVALUATION_DEPTH = 100 + +const BINARY_VALUE_RESOLVERS = new Map([ + [SyntaxKind.PlusToken, (left, right) => left + right], + [SyntaxKind.AmpersandAmpersandToken, (left, right) => (left ? right : left)], + [SyntaxKind.BarBarToken, (left, right) => (left ? left : right)], + [SyntaxKind.QuestionQuestionToken, (left, right) => (left === null ? right : left)], +]) + +const UPDATE_OPERATORS = new Set([SyntaxKind.PlusPlusToken, SyntaxKind.MinusMinusToken]) const FUNCTION_SCOPE_KINDS = new Set([ SyntaxKind.FunctionDeclaration, @@ -74,17 +79,27 @@ const BARRIER_DECLARATION_KINDS = new Set([ ]) function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { - return { parent, kind, isVarScope, isConstantBoundary, bindings: new Map() } + const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region + return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map() } } function declareBinding(scope, name, binding) { + const regionBindings = scope.region.bindings.get(name) ?? [] + regionBindings.push(binding) + scope.region.bindings.set(name, regionBindings) if (scope.bindings.has(name)) { - scope.bindings.set(name, { kind: 'barrier' }) + const duplicate = { kind: 'barrier' } + regionBindings.push(duplicate) + scope.bindings.set(name, duplicate) return } scope.bindings.set(name, binding) } +function hasModifier(node, kind) { + return node.modifiers?.some((modifier) => modifier.kind === kind) ?? false +} + function bindingNames(name) { if (isIdentifier(name)) return [name.text] if (name.kind !== SyntaxKind.ObjectBindingPattern && name.kind !== SyntaxKind.ArrayBindingPattern) { @@ -93,9 +108,9 @@ function bindingNames(name) { return name.elements.flatMap((element) => (element.name ? bindingNames(element.name) : [])) } -function declareBarrier(scope, name) { +function declareBarrier(scope, name, binding = { kind: 'barrier' }) { for (const identifier of bindingNames(name)) { - declareBinding(scope, identifier, { kind: 'barrier' }) + declareBinding(scope, identifier, binding) } } @@ -105,25 +120,41 @@ function nearestVarScope(scope) { return current } -function declareVariableList(declarationList, scope) { +function variableBinding(declaration, declarationScope, isConst, isResolvableConst) { + if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { + return { + kind: 'constant', + initializer: declaration.initializer, + scope: declarationScope, + availableAfter: declaration.end, + } + } + if (isIdentifier(declaration.name) && !isConst) { + return { + kind: 'mutable', + initializer: declaration.initializer, + scope: declarationScope, + mutationPositions: [], + availableAfter: declaration.end, + } + } + return { kind: 'unknown-shadow', availableAfter: declaration.end } +} + +function declareVariableList(declarationList, scope, { resolveLoopConstants = false } = {}) { const isConst = Boolean(declarationList.flags & NodeFlags.Const) const isBlockScoped = Boolean(declarationList.flags & NodeFlags.BlockScoped) const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) - // Rolldown keeps loop-header identifier imports dynamic even when the - // header declares a const literal, so those bindings remain barriers. - const isResolvableConst = isConst && declarationScope.kind !== 'loop' + const isResolvableConst = + isConst && (declarationScope.kind !== 'loop' || resolveLoopConstants) for (const declaration of declarationList.declarations) { - if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { - declareBinding(declarationScope, declaration.name.text, { - kind: 'constant', - initializer: declaration.initializer, - scope: declarationScope, - availableAfter: declaration.end, - }) - continue + const binding = variableBinding(declaration, declarationScope, isConst, isResolvableConst) + if (isIdentifier(declaration.name)) { + declareBinding(declarationScope, declaration.name.text, binding) + } else { + declareBarrier(declarationScope, declaration.name, binding) } - declareBarrier(declarationScope, declaration.name) } } @@ -146,7 +177,7 @@ function declareImportBindings(node, scope) { function createFunctionLexicalScope(node, currentScope) { if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { - declareBarrier(currentScope, node.name) + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } const functionScope = createScope(currentScope, 'function', true, true) @@ -159,7 +190,7 @@ function createFunctionLexicalScope(node, currentScope) { function createClassLexicalScope(node, currentScope) { if (node.kind === SyntaxKind.ClassDeclaration && node.name) { - declareBarrier(currentScope, node.name) + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } const classScope = createScope(currentScope, 'class', false, true) @@ -190,15 +221,36 @@ function createChildLexicalScope(node, currentScope) { ) } +function constEnumMemberDescriptor(member, index) { + const supportedName = + isIdentifier(member.name) || isStringLiteral(member.name) || isNumericLiteral(member.name) + return supportedName ? { member, index, name: member.name.text } : undefined +} + +function declareConstEnum(node, currentScope) { + const memberList = node.members.map(constEnumMemberDescriptor) + const members = new Map( + memberList.filter(Boolean).map((descriptor) => [descriptor.name, descriptor]), + ) + declareBinding(currentScope, node.name.text, { + kind: 'const-enum', + members, + memberList, + scope: currentScope, + cachedValues: new Map(), + }) +} + +function isConstEnumDeclaration(node) { + return isEnumDeclaration(node) && hasModifier(node, SyntaxKind.ConstKeyword) +} + function predeclareNodeBindings(node, currentScope) { - if (node.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node, currentScope) - return - } + if (node.kind === SyntaxKind.VariableDeclarationList) return declareVariableList(node, currentScope) if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { - declareImportBindings(node, currentScope) - return + return declareImportBindings(node, currentScope) } + if (isConstEnumDeclaration(node)) return declareConstEnum(node, currentScope) if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { declareBarrier(currentScope, node.name) } @@ -221,8 +273,14 @@ function buildLexicalScopes(sourceFile) { // Rolldown folds outer constants in a classic-for initializer, then // stops carrying them through the condition, update, and body. if (node.initializer?.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node.initializer, loopScope) - visitLoopHeader(node.initializer, currentScope) + const initializerScope = createScope(currentScope, 'loop-initializer') + declareVariableList(node.initializer, initializerScope, { + resolveLoopConstants: true, + }) + for (const declaration of node.initializer.declarations) { + declareBarrier(loopScope, declaration.name) + } + visitLoopHeader(node.initializer, initializerScope) } else { visit(node.initializer, currentScope) } @@ -234,14 +292,22 @@ function buildLexicalScopes(sourceFile) { if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { if (node.initializer.kind === SyntaxKind.VariableDeclarationList) { - declareVariableList(node.initializer, loopScope) - visitLoopHeader(node.initializer, currentScope) + const initializerScope = createScope(currentScope, 'loop-initializer') + declareVariableList(node.initializer, initializerScope, { + resolveLoopConstants: true, + }) + for (const declaration of node.initializer.declarations) { + declareBarrier(loopScope, declaration.name) + } + visitLoopHeader(node.initializer, initializerScope) + // A lexical for-in/of binding is in its temporal dead zone while the + // collection expression is evaluated. Different names still see the + // surrounding declaration environment. + visit(node.expression, initializerScope) } else { visit(node.initializer, currentScope) + visit(node.expression, currentScope) } - // The collection expression behaves like an initializer; the repeated - // body is the constant-resolution boundary. - visit(node.expression, currentScope) visit(node.statement, loopScope) return } @@ -263,6 +329,7 @@ function buildLexicalScopes(sourceFile) { } visit(sourceFile, sourceScope) + markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) return { nodeScopes, sourceScope } } @@ -279,46 +346,204 @@ function findBinding(scope, name) { return undefined } +function findLexicalBinding(scope, name) { + let current = scope + while (current) { + const binding = current.bindings.get(name) + if (binding) return binding + current = current.parent + } + return undefined +} + +function assignmentTargetIdentifier(node) { + const assignment = + isBinaryExpression(node) && + node.operatorToken.kind >= SyntaxKind.FirstAssignment && + node.operatorToken.kind <= SyntaxKind.LastAssignment + if (assignment && isIdentifier(node.left)) return node.left + const update = + isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node) + ? UPDATE_OPERATORS.has(node.operator) + : false + return update && isIdentifier(node.operand) ? node.operand : undefined +} + +function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { + walkAst(sourceFile, (node) => { + const identifier = assignmentTargetIdentifier(node) + if (!identifier) return + const scope = nodeScopes.get(identifier) ?? nodeScopes.get(node) ?? sourceScope + const binding = findLexicalBinding(scope, identifier.text) + if (binding?.kind === 'mutable') { + binding.mutationPositions.push(node.end) + } + }) +} + function isBindingAvailable(binding, referencePosition) { return binding.availableAfter === undefined || referencePosition >= binding.availableAfter } -function evaluateTemplateString(expression, scope, resolving) { +function evaluateTemplateValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, +) { let value = expression.head.text const dependencies = [] for (const span of expression.templateSpans) { - const interpolation = evaluateConstantString(span.expression, scope, resolving) + const interpolation = evaluateConstantValue( + span.expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) if (!interpolation) return undefined - value += interpolation.value + span.literal.text + value += String(interpolation.value) + span.literal.text dependencies.push(...interpolation.dependencies) } return { value, dependencies } } -function evaluateConcatenatedString(expression, scope, resolving) { - if (expression.operatorToken.kind !== SyntaxKind.PlusToken) return undefined - const left = evaluateConstantString(expression.left, scope, resolving) - const right = evaluateConstantString(expression.right, scope, resolving) - if (!left || !right) return undefined +function evaluateBinaryValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, +) { + const left = evaluateConstantValue( + expression.left, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) + if (!left) return undefined + + const operator = expression.operatorToken.kind + const resolver = BINARY_VALUE_RESOLVERS.get(operator) + if (!resolver) return undefined + const shortCircuitValue = resolver(left.value, undefined) + if (shortCircuitValue !== undefined && operator !== SyntaxKind.PlusToken) return left + + const right = evaluateConstantValue( + expression.right, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) + if (!right) return undefined return { - value: left.value + right.value, + value: resolver(left.value, right.value), dependencies: [...left.dependencies, ...right.dependencies], } } -function evaluateConstantBindingValue(binding, resolving) { +function evaluateConstantBindingValue(binding, resolving, depth) { // Bindings are stable identities, so aliases keep the declaration-time // environment even when the same name is shadowed at a later use site. if (binding.cachedValue !== undefined) return binding.cachedValue - if (resolving.has(binding)) return undefined + if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined const nextResolving = new Set(resolving).add(binding) - const result = evaluateConstantString(binding.initializer, binding.scope, nextResolving) + const result = evaluateConstantValue( + binding.initializer, + binding.scope, + nextResolving, + binding.scope, + binding.initializer.getStart(), + undefined, + depth + 1, + ) binding.cachedValue = result ?? null return result } -function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving) { +function mutableShadowBlocks(candidate, referencePosition, resolving, depth) { + if (candidate.mutationPositions.some((position) => position <= referencePosition)) return true + if (!candidate.initializer) return false + return !evaluateConstantValue( + candidate.initializer, + candidate.scope, + resolving, + candidate.scope, + candidate.initializer.getStart(), + undefined, + depth + 1, + ) +} + +function regionCandidateBlocks(candidate, dependency, referencePosition, resolving, depth) { + if (candidate === dependency.binding || !isBindingAvailable(candidate, referencePosition)) { + return false + } + if (candidate.kind === 'constant') { + return !evaluateConstantBindingValue(candidate, resolving, depth + 1) + } + if (candidate.kind === 'mutable') { + return mutableShadowBlocks(candidate, referencePosition, resolving, depth + 1) + } + return candidate.kind === 'barrier' || candidate.kind === 'unknown-shadow' +} + +function regionHasBlockingShadow( + dependency, + referenceScope, + referencePosition, + resolving, + depth, +) { + const candidates = referenceScope.region.bindings.get(dependency.name) ?? [] + return candidates.some((candidate) => + regionCandidateBlocks(candidate, dependency, referencePosition, resolving, depth + 1), + ) +} + +function visibleBindingAllowsCapture(binding, referencePosition, resolving, depth) { + if (binding.kind === 'constant') { + // Rolldown eliminates any proven literal shadow before folding the alias; + // the shadow does not need to have the captured dependency's value. + return Boolean(evaluateConstantBindingValue(binding, resolving, depth + 1)) + } + if (binding.kind === 'const-enum' || binding.kind === 'static-shadow') return true + if (binding.kind !== 'mutable') return false + return !mutableShadowBlocks(binding, referencePosition, resolving, depth + 1) +} + +function dependencyMatchesUseSite( + dependency, + referenceScope, + referencePosition, + resolving, + depth, +) { + if ( + regionHasBlockingShadow( + dependency, + referenceScope, + referencePosition, + resolving, + depth + 1, + ) + ) { + return false + } const visibleBinding = findBinding(referenceScope, dependency.name) if ( !visibleBinding || @@ -327,15 +552,17 @@ function dependencyMatchesUseSite(dependency, referenceScope, referencePosition, ) { return true } - if (visibleBinding.kind !== 'constant') return false - - // A same-value shadow is eliminated by Rolldown and does not prevent the - // captured alias from becoming a literal import. An unknown shadow does. - const visibleValue = evaluateConstantBindingValue(visibleBinding, resolving) - return visibleValue?.value === dependency.value + return visibleBindingAllowsCapture(visibleBinding, referencePosition, resolving, depth + 1) } -function evaluateConstantBinding(expression, scope, resolving, referenceScope, referencePosition) { +function evaluateConstantBinding( + expression, + scope, + resolving, + referenceScope, + referencePosition, + depth, +) { const binding = findBinding(scope, expression.text) if ( !binding || @@ -346,7 +573,7 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r return undefined } - const value = evaluateConstantBindingValue(binding, resolving) + const value = evaluateConstantBindingValue(binding, resolving, depth + 1) if (!value) return undefined const dependencies = [ { name: expression.text, binding, value: value.value }, @@ -354,7 +581,13 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r ] if ( !dependencies.every((dependency) => - dependencyMatchesUseSite(dependency, referenceScope, referencePosition, resolving), + dependencyMatchesUseSite( + dependency, + referenceScope, + referencePosition, + resolving, + depth + 1, + ), ) ) { return undefined @@ -362,57 +595,362 @@ function evaluateConstantBinding(expression, scope, resolving, referenceScope, r return { value: value.value, dependencies } } -function evaluateConstantString( +function constEnumMemberName(expression) { + if (isPropertyAccessExpression(expression) && isIdentifier(expression.expression)) { + return { enumName: expression.expression.text, memberName: expression.name.text } + } + if ( + isElementAccessExpression(expression) && + isIdentifier(expression.expression) && + (isStringLiteral(expression.argumentExpression) || + isNumericLiteral(expression.argumentExpression)) + ) { + return { + enumName: expression.expression.text, + memberName: expression.argumentExpression.text, + } + } + return undefined +} + +function evaluateImplicitConstEnumMember(binding, descriptor, resolving, depth) { + if (descriptor.index === 0) return { value: 0, dependencies: [] } + const previous = binding.memberList[descriptor.index - 1] + if (!previous) return undefined + const previousValue = evaluateConstEnumMember( + binding, + previous.name, + resolving, + depth + 1, + ) + if (typeof previousValue?.value !== 'number') return undefined + return { + value: previousValue.value + 1, + dependencies: previousValue.dependencies, + } +} + +function evaluateExplicitConstEnumMember(binding, descriptor, resolving, depth) { + const initializer = descriptor.member.initializer + return evaluateConstantValue( + initializer, + binding.scope, + resolving, + binding.scope, + initializer.getStart(), + binding, + depth + 1, + ) +} + +function evaluateConstEnumMember(binding, memberName, resolving, depth) { + if (binding.cachedValues.has(memberName)) { + return binding.cachedValues.get(memberName) ?? undefined + } + const descriptor = binding.members.get(memberName) + if ( + !descriptor || + resolving.has(descriptor) || + depth > MAX_CONSTANT_EVALUATION_DEPTH + ) { + return undefined + } + + const nextResolving = new Set(resolving).add(descriptor) + const result = descriptor.member.initializer + ? evaluateExplicitConstEnumMember(binding, descriptor, nextResolving, depth + 1) + : evaluateImplicitConstEnumMember(binding, descriptor, nextResolving, depth + 1) + binding.cachedValues.set(memberName, result ?? null) + return result +} + +function evaluateConstEnumAccess(expression, scope, resolving, depth) { + const access = constEnumMemberName(expression) + if (!access) return undefined + const binding = findBinding(scope, access.enumName) + if (!binding || binding.kind !== 'const-enum') return undefined + const value = evaluateConstEnumMember(binding, access.memberName, resolving, depth + 1) + if (!value) return undefined + return { + value: value.value, + dependencies: [ + { name: access.enumName, binding, value: value.value }, + ...value.dependencies, + ], + } +} + +function evaluateLiteralExpression(expression) { + if (isNumericLiteral(expression)) { + return { value: Number(expression.text), dependencies: [] } + } + return { value: expression.text, dependencies: [] } +} + +function evaluateKeywordExpression(expression) { + const values = new Map([ + [SyntaxKind.TrueKeyword, true], + [SyntaxKind.FalseKeyword, false], + [SyntaxKind.NullKeyword, null], + ]) + return { value: values.get(expression.kind), dependencies: [] } +} + +function evaluateWrappedExpression(expression, context) { + return evaluateConstantValue( + expression.expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) +} + +function evaluateTemplateExpressionValue(expression, context) { + return evaluateTemplateValue( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth, + ) +} + +function evaluateConditionalExpressionValue(expression, context) { + const condition = evaluateConstantValue( + expression.condition, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) + if (!condition) return undefined + const branch = condition.value ? expression.whenTrue : expression.whenFalse + const result = evaluateConstantValue( + branch, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth + 1, + ) + if (!result) return undefined + return { + value: result.value, + dependencies: [...condition.dependencies, ...result.dependencies], + } +} + +function evaluateBinaryExpressionValue(expression, context) { + return evaluateBinaryValue( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.enumBinding, + context.depth, + ) +} + +function evaluateIdentifierExpressionValue(expression, context) { + if (context.enumBinding?.members.has(expression.text)) { + return evaluateConstEnumMember( + context.enumBinding, + expression.text, + context.resolving, + context.depth + 1, + ) + } + return evaluateConstantBinding( + expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth, + ) +} + +function evaluateConstEnumAccessValue(expression, context) { + return evaluateConstEnumAccess( + expression, + context.scope, + context.resolving, + context.depth, + ) +} + +const CONSTANT_VALUE_HANDLERS = new Map([ + [SyntaxKind.StringLiteral, evaluateLiteralExpression], + [SyntaxKind.NoSubstitutionTemplateLiteral, evaluateLiteralExpression], + [SyntaxKind.NumericLiteral, evaluateLiteralExpression], + [SyntaxKind.TrueKeyword, evaluateKeywordExpression], + [SyntaxKind.FalseKeyword, evaluateKeywordExpression], + [SyntaxKind.NullKeyword, evaluateKeywordExpression], + [SyntaxKind.ParenthesizedExpression, evaluateWrappedExpression], + [SyntaxKind.AsExpression, evaluateWrappedExpression], + [SyntaxKind.NonNullExpression, evaluateWrappedExpression], + [SyntaxKind.SatisfiesExpression, evaluateWrappedExpression], + [SyntaxKind.TypeAssertionExpression, evaluateWrappedExpression], + [SyntaxKind.TemplateExpression, evaluateTemplateExpressionValue], + [SyntaxKind.ConditionalExpression, evaluateConditionalExpressionValue], + [SyntaxKind.BinaryExpression, evaluateBinaryExpressionValue], + [SyntaxKind.Identifier, evaluateIdentifierExpressionValue], + [SyntaxKind.PropertyAccessExpression, evaluateConstEnumAccessValue], + [SyntaxKind.ElementAccessExpression, evaluateConstEnumAccessValue], +]) + +function evaluateConstantValue( expression, scope, resolving = new Set(), referenceScope = scope, referencePosition = expression?.getStart() ?? 0, + enumBinding, + depth = 0, ) { - if (!expression) return undefined - if (isStringLiteral(expression) || isNoSubstitutionTemplateLiteral(expression)) { - return { value: expression.text, dependencies: [] } + if (!expression || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + const handler = CONSTANT_VALUE_HANDLERS.get(expression.kind) + if (!handler) return undefined + return handler(expression, { + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, + }) +} + +function possibleWrappedTarget(expression, context) { + return expressionMayResolveToReactHotkeys( + expression.expression, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) +} + +function possibleConditionalTarget(expression, context) { + return [expression.whenTrue, expression.whenFalse].some((branch) => + expressionMayResolveToReactHotkeys( + branch, + context.scope, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ), + ) +} + +function possibleIdentifierTarget(expression, context) { + const { scope, resolving, referenceScope, referencePosition, depth } = context + const binding = findBinding(scope, expression.text) + if ( + !binding || + binding.kind !== 'constant' || + resolving.has(binding) || + !isBindingAvailable(binding, expression.getStart()) + ) { + return false } - if (CONSTANT_STRING_WRAPPER_CHECKS.some((check) => check(expression))) { - return evaluateConstantString( - expression.expression, - scope, - resolving, + const dependency = { name: expression.text, binding } + if ( + !dependencyMatchesUseSite( + dependency, referenceScope, referencePosition, - ) - } - if (isTemplateExpression(expression)) { - return evaluateTemplateString(expression, scope, resolving) - } - if (isBinaryExpression(expression)) { - return evaluateConcatenatedString(expression, scope, resolving) - } - if (isIdentifier(expression)) { - return evaluateConstantBinding( - expression, - scope, resolving, - referenceScope, - referencePosition, + depth + 1, ) + ) { + return false } - return undefined + return expressionMayResolveToReactHotkeys( + binding.initializer, + binding.scope, + new Set(resolving).add(binding), + referenceScope, + referencePosition, + depth + 1, + ) +} + +const POSSIBLE_TARGET_HANDLERS = new Map([ + [SyntaxKind.ParenthesizedExpression, possibleWrappedTarget], + [SyntaxKind.AsExpression, possibleWrappedTarget], + [SyntaxKind.NonNullExpression, possibleWrappedTarget], + [SyntaxKind.SatisfiesExpression, possibleWrappedTarget], + [SyntaxKind.TypeAssertionExpression, possibleWrappedTarget], + [SyntaxKind.ConditionalExpression, possibleConditionalTarget], + [SyntaxKind.Identifier, possibleIdentifierTarget], +]) + +function expressionMayResolveToReactHotkeys( + expression, + scope, + resolving = new Set(), + referenceScope = scope, + referencePosition = expression?.getStart() ?? 0, + depth = 0, +) { + if (!expression || depth > MAX_CONSTANT_EVALUATION_DEPTH) return false + const exact = evaluateConstantValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + if (exact) return exact.value === REACT_HOTKEYS_HOOK_MODULE + const handler = POSSIBLE_TARGET_HANDLERS.get(expression.kind) + if (!handler) return false + return handler(expression, { scope, resolving, referenceScope, referencePosition, depth }) } function isReactHotkeysSource(source, scope) { - return evaluateConstantString(source, scope)?.value === REACT_HOTKEYS_HOOK_MODULE + return expressionMayResolveToReactHotkeys(source, scope) +} + +function hasOnlyTypeSpecifiers(elements) { + return elements?.length > 0 && elements.every((element) => element.isTypeOnly) +} + +function isRuntimeImportDeclaration(node) { + const clause = node.importClause + if (!clause) return true + if (clause.isTypeOnly) return false + if (clause.name) return true + return !hasOnlyTypeSpecifiers(clause.namedBindings?.elements) +} + +function isRuntimeExportDeclaration(node) { + if (node.isTypeOnly) return false + return !hasOnlyTypeSpecifiers(node.exportClause?.elements) } function isStaticReactHotkeysImport(node, scope) { - return ( - (isImportDeclaration(node) || isExportDeclaration(node)) && - isReactHotkeysSource(node.moduleSpecifier, scope) - ) + const runtimeDeclaration = isImportDeclaration(node) + ? isRuntimeImportDeclaration(node) + : isExportDeclaration(node) && isRuntimeExportDeclaration(node) + return runtimeDeclaration && isReactHotkeysSource(node.moduleSpecifier, scope) } function isTypeScriptReactHotkeysImport(node, scope) { - if (!isImportEqualsDeclaration(node)) return false + if (!isImportEqualsDeclaration(node) || node.isTypeOnly) return false const reference = node.moduleReference return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression, scope) } @@ -420,7 +958,10 @@ function isTypeScriptReactHotkeysImport(node, scope) { function isReactHotkeysCallImport(node, scope) { if (!isCallExpression(node)) return false const { expression, arguments: args } = node - const isRequire = isIdentifier(expression) && expression.text === 'require' + const isRequire = + isIdentifier(expression) && + expression.text === 'require' && + !findLexicalBinding(scope, expression.text) const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword return ( (isRequire || isDynamicImport) && @@ -468,6 +1009,51 @@ export function findReactHotkeysHookImportViolations( try { snapshot = compiler.updateSnapshot({ openProjects: [`${virtualRoot}/tsconfig.json`] }) const project = snapshot.getProjects()[0] + if (!project) throw new Error('TypeScript could not create the in-memory boundary project') + + const syntaxErrors = project.program + .getSyntacticDiagnostics() + .flatMap((diagnostic) => { + const candidate = virtualSources.get(diagnostic.fileName) + const sourceFile = project.program.getSourceFile(diagnostic.fileName) + if (!candidate || !sourceFile) return [] + const position = Math.min(diagnostic.pos ?? 0, sourceFile.end) + const location = sourceFile.getLineAndCharacterOfPosition(position) + return [ + { + path: candidate.path, + line: location.line + 1, + column: location.character + 1, + code: diagnostic.code, + text: diagnostic.text ?? 'Invalid TypeScript syntax', + }, + ] + }) + .toSorted( + (left, right) => + left.path.localeCompare(right.path) || + left.line - right.line || + left.column - right.column || + left.code - right.code, + ) + .filter( + (diagnostic, index, diagnostics) => + index === 0 || + diagnostic.path !== diagnostics[index - 1].path || + diagnostic.line !== diagnostics[index - 1].line || + diagnostic.column !== diagnostics[index - 1].column || + diagnostic.code !== diagnostics[index - 1].code, + ) + if (syntaxErrors.length > 0) { + throw new SyntaxError( + `Runtime hotkey import boundary could not parse source:\n${syntaxErrors + .map( + ({ path, line, column, code, text }) => + `${path}:${line}:${column} TS${code}: ${text}`, + ) + .join('\n')}`, + ) + } for (const [virtualPath, { path }] of virtualSources) { const sourceFile = project?.program.getSourceFile(virtualPath) @@ -534,4 +1120,11 @@ function runCli() { } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined -if (invokedPath === fileURLToPath(import.meta.url)) runCli() +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + runCli() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 657dbdb76..94dfeaebf 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -234,6 +234,364 @@ const ROLLDOWN_PARITY_CASES = [ "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; { const pkg = moduleName(); return import(pkg) } }", resolves: false, }, + { + name: 'captured alias under different literal shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'other'; return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under uninitialized let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under unknown let shadow', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias under mutated let shadow', + source: + "declare function moduleName(): string; export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { let pkg; pkg = moduleName(); return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias under function shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { function pkg() {} return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias under class shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { class pkg {} return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias with unknown sibling shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = moduleName() } { return import(alias) } }", + resolves: false, + }, + { + name: 'captured alias with known sibling shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; { const pkg = 'other' } { return import(alias) } }", + resolves: true, + }, + { + name: 'captured alias across closure parameter', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; return function inner(pkg: string) { return import(alias) } }", + resolves: false, + }, + { + name: 'direct let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { let pkg; return import(pkg) } }", + resolves: false, + }, + { + name: 'direct var shadow', + source: + "const pkg = 'react-hotkeys-hook'; export function load() { import(pkg); var pkg: string }", + resolves: false, + }, + { + name: 'direct destructuring shadow', + source: + "export function load(value: { pkg: string }) { const pkg = 'react-hotkeys-hook'; { const { pkg } = value; return import(pkg) } }", + resolves: false, + }, + { + name: 'direct import binding shadow', + source: "import pkg from 'runtime-name'; export function load() { return import(pkg) }", + resolves: false, + }, + { + name: 'direct function shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { return import(pkg); function pkg() {} } }", + resolves: false, + }, + { + name: 'direct class shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; { return import(pkg); class pkg {} } }", + resolves: false, + }, + { + name: 'finally outer const', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; try {} finally { return import(pkg) } }", + resolves: true, + }, + { + name: 'finally captured alias under let shadow', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; try {} finally { let pkg; return import(alias) } }", + resolves: true, + }, + { + name: 'catch captured alias boundary', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; const alias = pkg; try { throw 1 } catch { return import(alias) } }", + resolves: false, + }, + { + name: 'classic for sequential declarator', + source: + "export function load() { for (const pkg = 'react-hotkeys-hook', pending = import(pkg); ;) break }", + resolves: true, + }, + { + name: 'classic for later declarator temporal dead zone', + source: + "export function load() { for (const pending = import(pkg), pkg = 'react-hotkeys-hook'; ;) break }", + resolves: false, + }, + { + name: 'classic for current declarator temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg = import(pkg); ;) break }", + resolves: false, + }, + { + name: 'for-in same-name expression temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg in import(pkg)) break }", + resolves: false, + }, + { + name: 'for-of same-name expression temporal dead zone', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const pkg of import(pkg)) break }", + resolves: false, + }, + { + name: 'for-in different-name expression', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const key in import(pkg)) break }", + resolves: true, + }, + { + name: 'for-of different-name expression', + source: + "export function load() { const pkg = 'react-hotkeys-hook'; for (const value of import(pkg)) break }", + resolves: true, + }, + { + name: 'conditional true branch', + source: "const pkg = true ? 'react-hotkeys-hook' : 'other'; import(pkg)", + resolves: true, + }, + { + name: 'conditional false branch', + source: "const pkg = false ? 'other' : 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'conditional non-target result', + source: "const pkg = true ? 'other' : 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'conditional unknown condition', + source: + "declare const enabled: boolean; const pkg = enabled ? 'react-hotkeys-hook' : 'other'; import(pkg)", + resolves: true, + }, + { + name: 'conditional wholly dynamic result', + source: + 'declare const enabled: boolean; declare const first: string; declare const second: string; const pkg = enabled ? first : second; import(pkg)', + resolves: false, + }, + { + name: 'logical and truthy boolean', + source: "const pkg = true && 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical and truthy number', + source: "const pkg = 1 && 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical and falsy boolean', + source: "const pkg = false && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical and falsy number', + source: "const pkg = 0 && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical or falsy boolean', + source: "const pkg = false || 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical or falsy number', + source: "const pkg = 0 || 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'logical or truthy boolean', + source: "const pkg = true || 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'logical or truthy string', + source: "const pkg = 'other' || 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'nullish null', + source: "const pkg = null ?? 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'nullish non-null number', + source: "const pkg = 0 ?? 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'unknown logical operand', + source: + "declare const enabled: boolean; const pkg = enabled && 'react-hotkeys-hook'; import(pkg)", + resolves: false, + }, + { + name: 'unknown concatenated operand', + source: "declare const suffix: string; const pkg = 'react-hotkeys-' + suffix; import(pkg)", + resolves: false, + }, + { + name: 'wrapped logical expression', + source: + "const pkg = (((true && 'react-hotkeys-hook') as string)!) satisfies string; import(pkg)", + resolves: true, + }, + { + name: 'const enum property member', + source: "const enum Modules { Hotkeys = 'react-hotkeys-hook' } import(Modules.Hotkeys)", + resolves: true, + }, + { + name: 'const enum element member', + source: "const enum Modules { Hotkeys = 'react-hotkeys-hook' } import(Modules['Hotkeys'])", + resolves: true, + }, + { + name: 'const enum member alias', + source: + "const enum Modules { Hotkeys = 'react-hotkeys-hook', Alias = Hotkeys } import(Modules.Alias)", + resolves: true, + }, + { + name: 'const enum non-target member', + source: + "const enum Modules { Hotkeys = 'react-hotkeys-hook', Other = 'other' } import(Modules.Other)", + resolves: false, + }, + { + name: 'const enum automatic numeric member', + source: 'const enum Modules { Other } import(Modules.Other)', + resolves: false, + }, + { + name: 'const enum member cycle', + source: 'const enum Modules { First = Second, Second = First } import(Modules.First)', + resolves: false, + }, + { + name: 'global require', + source: "export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'require parameter shadow', + source: + "export function load(require: (id: string) => unknown) { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require destructuring parameter shadow', + source: + "export function load({ require }: { require: (id: string) => unknown }) { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require local const shadow', + source: + "export function load() { const require = (id: string) => id; return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require local function shadow', + source: + "export function load() { return require('react-hotkeys-hook'); function require(id: string) { return id } }", + resolves: false, + }, + { + name: 'require import shadow', + source: + "import { require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'require catch shadow', + source: + "export function load() { try { throw (() => undefined) } catch (require) { return require('react-hotkeys-hook') } }", + resolves: false, + }, + { + name: 'require sibling unshadowed', + source: + "export function load() { { const require = (id: string) => id; require('react-hotkeys-hook') } return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only import declaration', + source: "import type { HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'type-only import specifier', + source: "import { type HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'mixed value and type import', + source: + "import { type HotkeyCallback, useHotkeys } from 'react-hotkeys-hook'; console.log(useHotkeys)", + resolves: true, + }, + { + name: 'type-only export declaration', + source: "export type { HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'type-only export specifier', + source: "export { type HotkeyCallback } from 'react-hotkeys-hook'", + resolves: false, + }, + { + name: 'mixed value and type export', + source: "export { type HotkeyCallback, useHotkeys } from 'react-hotkeys-hook'", + resolves: true, + }, + { + name: 'type-only import equals', + source: "import type Hotkeys = require('react-hotkeys-hook')", + resolves: false, + }, ] as const const ROLLDOWN_PARITY_SCRIPT = ` @@ -260,7 +618,11 @@ const ROLLDOWN_PARITY_SCRIPT = ` const generated = await bundle.generate({ format: 'es' }) const chunk = generated.output.find((output) => output.type === 'chunk') if (!chunk) throw new Error('Rolldown did not generate a JavaScript chunk') - resolutions.push(/import\\(["']react-hotkeys-hook["']\\)/.test(chunk.code)) + resolutions.push( + /(?:from\\s+|import\\s*\\(|import\\s+|__require\\s*\\()\\s*["']react-hotkeys-hook["']/.test( + chunk.code, + ), + ) } finally { await bundle.close() } @@ -455,6 +817,24 @@ describe('runtime hotkey registration coverage', () => { ).toEqual([]) }) + it('fails transparently and deterministically on malformed source', () => { + const sources = [ + { + path: 'src/features/z-malformed.ts', + source: "const hooks = import('react-hotkeys-hook'", + }, + { path: 'src/features/a-malformed.ts', source: 'export const value = }' }, + ] + + expect(() => findReactHotkeysHookImportViolations(sources)).toThrowError( + new SyntaxError( + 'Runtime hotkey import boundary could not parse source:\n' + + 'src/features/a-malformed.ts:1:22 TS1109: Expression expected.\n' + + "src/features/z-malformed.ts:1:42 TS1005: ')' expected.", + ), + ) + }) + it('reports the exact source location and allowed adapter', () => { const path = 'src/features/multiline-import.ts' const source = "// setup\nconst hooks = await import('react-hotkeys-hook')" From 25f751d48718bed41d3cfd9bb47345d367904150 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 09:19:08 -0700 Subject: [PATCH 20/21] fix(qa): track mutable and erased runtime bindings --- scripts/runtime-hotkey-import-boundary.mjs | 415 +++++++++++++++--- ...ntime-hotkey-registration-coverage.test.ts | 149 +++++++ 2 files changed, 513 insertions(+), 51 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 85c92b679..7daceddad 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -78,9 +78,28 @@ const BARRIER_DECLARATION_KINDS = new Set([ SyntaxKind.ModuleDeclaration, ]) -function createScope(parent, kind, isVarScope = false, isConstantBoundary = false) { +const UNCERTAIN_WRITE_ANCESTOR_KINDS = new Set([ + SyntaxKind.ConditionalExpression, + SyntaxKind.DoStatement, + SyntaxKind.ForInStatement, + SyntaxKind.ForOfStatement, + SyntaxKind.ForStatement, + SyntaxKind.IfStatement, + SyntaxKind.SwitchStatement, + SyntaxKind.TryStatement, + SyntaxKind.WhileStatement, + SyntaxKind.WithStatement, +]) + +function createScope( + parent, + kind, + isVarScope = false, + isConstantBoundary = false, + owner, +) { const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region - return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map() } + return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map(), owner } } function declareBinding(scope, name, binding) { @@ -97,7 +116,16 @@ function declareBinding(scope, name, binding) { } function hasModifier(node, kind) { - return node.modifiers?.some((modifier) => modifier.kind === kind) ?? false + return node?.modifiers?.some((modifier) => modifier.kind === kind) ?? false +} + +function isAmbientDeclaration(node) { + let current = node + while (current) { + if (hasModifier(current, SyntaxKind.DeclareKeyword)) return true + current = current.parent + } + return false } function bindingNames(name) { @@ -120,7 +148,7 @@ function nearestVarScope(scope) { return current } -function variableBinding(declaration, declarationScope, isConst, isResolvableConst) { +function variableBinding(declaration, declarationScope, isConst, isResolvableConst, isHoisted) { if (isResolvableConst && isIdentifier(declaration.name) && declaration.initializer) { return { kind: 'constant', @@ -135,7 +163,9 @@ function variableBinding(declaration, declarationScope, isConst, isResolvableCon initializer: declaration.initializer, scope: declarationScope, mutationPositions: [], - availableAfter: declaration.end, + writes: [], + isHoisted, + availableAfter: isHoisted ? 0 : declaration.end, } } return { kind: 'unknown-shadow', availableAfter: declaration.end } @@ -147,9 +177,18 @@ function declareVariableList(declarationList, scope, { resolveLoopConstants = fa const declarationScope = isBlockScoped ? scope : nearestVarScope(scope) const isResolvableConst = isConst && (declarationScope.kind !== 'loop' || resolveLoopConstants) + const isAmbient = isAmbientDeclaration(declarationList) + + if (isAmbient) return for (const declaration of declarationList.declarations) { - const binding = variableBinding(declaration, declarationScope, isConst, isResolvableConst) + const binding = variableBinding( + declaration, + declarationScope, + isConst, + isResolvableConst, + !isBlockScoped, + ) if (isIdentifier(declaration.name)) { declareBinding(declarationScope, declaration.name.text, binding) } else { @@ -159,28 +198,40 @@ function declareVariableList(declarationList, scope, { resolveLoopConstants = fa } function declareImportBindings(node, scope) { - if (isImportEqualsDeclaration(node)) { - declareBarrier(scope, node.name) - return - } + if (isImportEqualsDeclaration(node)) return declareImportEqualsBinding(node, scope) if (!isImportDeclaration(node) || !node.importClause) return + if (node.importClause.isTypeOnly) return const { name, namedBindings } = node.importClause if (name) declareBarrier(scope, name) + declareNamedImportBindings(namedBindings, scope) +} + +function declareImportEqualsBinding(node, scope) { + if (!node.isTypeOnly) declareBarrier(scope, node.name) +} + +function declareNamedImportBindings(namedBindings, scope) { if (!namedBindings) return if (namedBindings.name) { declareBarrier(scope, namedBindings.name) return } - for (const element of namedBindings.elements) declareBarrier(scope, element.name) + for (const element of namedBindings.elements) { + if (!element.isTypeOnly) declareBarrier(scope, element.name) + } } function createFunctionLexicalScope(node, currentScope) { - if (node.kind === SyntaxKind.FunctionDeclaration && node.name) { + if ( + node.kind === SyntaxKind.FunctionDeclaration && + node.name && + !isAmbientDeclaration(node) + ) { declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } - const functionScope = createScope(currentScope, 'function', true, true) + const functionScope = createScope(currentScope, 'function', true, true, node) if (NAMED_FUNCTION_SCOPE_KINDS.has(node.kind) && node.name) { declareBarrier(functionScope, node.name) } @@ -189,11 +240,11 @@ function createFunctionLexicalScope(node, currentScope) { } function createClassLexicalScope(node, currentScope) { - if (node.kind === SyntaxKind.ClassDeclaration && node.name) { + if (node.kind === SyntaxKind.ClassDeclaration && node.name && !isAmbientDeclaration(node)) { declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) } - const classScope = createScope(currentScope, 'class', false, true) + const classScope = createScope(currentScope, 'class', false, true, node) if (node.name) declareBarrier(classScope, node.name) return classScope } @@ -250,8 +301,14 @@ function predeclareNodeBindings(node, currentScope) { if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { return declareImportBindings(node, currentScope) } - if (isConstEnumDeclaration(node)) return declareConstEnum(node, currentScope) - if (BARRIER_DECLARATION_KINDS.has(node.kind) && node.name) { + if (isConstEnumDeclaration(node) && !isAmbientDeclaration(node)) { + return declareConstEnum(node, currentScope) + } + if ( + BARRIER_DECLARATION_KINDS.has(node.kind) && + node.name && + !isAmbientDeclaration(node) + ) { declareBarrier(currentScope, node.name) } } @@ -356,27 +413,91 @@ function findLexicalBinding(scope, name) { return undefined } -function assignmentTargetIdentifier(node) { - const assignment = - isBinaryExpression(node) && - node.operatorToken.kind >= SyntaxKind.FirstAssignment && - node.operatorToken.kind <= SyntaxKind.LastAssignment - if (assignment && isIdentifier(node.left)) return node.left - const update = - isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node) - ? UPDATE_OPERATORS.has(node.operator) - : false - return update && isIdentifier(node.operand) ? node.operand : undefined +function hasUncertainWriteAncestor(node) { + let current = node.parent + while (current) { + if (UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind)) return true + if ( + isBinaryExpression(current) && + (current.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken || + current.operatorToken.kind === SyntaxKind.BarBarToken || + current.operatorToken.kind === SyntaxKind.QuestionQuestionToken) + ) { + return true + } + current = current.parent + } + return false +} + +function crossesNestedFunction(scope, binding) { + let current = scope + while (current && current !== binding.scope) { + if (current.kind === 'function') return true + current = current.parent + } + return current !== binding.scope +} + +function assignmentWriteDescriptors(node) { + if (isBinaryExpression(node)) return binaryAssignmentWriteDescriptors(node) + if (isPrefixUnaryExpression(node) || isPostfixUnaryExpression(node)) { + return updateWriteDescriptors(node) + } + if (node.kind === SyntaxKind.ForInStatement || node.kind === SyntaxKind.ForOfStatement) { + return loopWriteDescriptors(node) + } + return [] +} + +function binaryAssignmentWriteDescriptors(node) { + if ( + node.operatorToken.kind < SyntaxKind.FirstAssignment || + node.operatorToken.kind > SyntaxKind.LastAssignment + ) { + return [] + } + const names = bindingNames(node.left) + if (names.length === 0) return [] + const isSimple = node.operatorToken.kind === SyntaxKind.EqualsToken + return names.map((name) => ({ + name, + expression: isSimple && isIdentifier(node.left) ? node.right : undefined, + isSimple: isSimple && isIdentifier(node.left), + })) +} + +function updateWriteDescriptors(node) { + if (!UPDATE_OPERATORS.has(node.operator) || !isIdentifier(node.operand)) return [] + return [{ name: node.operand.text, expression: undefined, isSimple: false }] +} + +function loopWriteDescriptors(node) { + return bindingNames(node.initializer).map((name) => ({ + name, + expression: undefined, + isSimple: false, + })) } function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { walkAst(sourceFile, (node) => { - const identifier = assignmentTargetIdentifier(node) - if (!identifier) return - const scope = nodeScopes.get(identifier) ?? nodeScopes.get(node) ?? sourceScope - const binding = findLexicalBinding(scope, identifier.text) - if (binding?.kind === 'mutable') { - binding.mutationPositions.push(node.end) + const descriptors = assignmentWriteDescriptors(node) + if (descriptors.length === 0) return + const scope = nodeScopes.get(node) ?? sourceScope + const isUncertain = hasUncertainWriteAncestor(node) + for (const descriptor of descriptors) { + const binding = findLexicalBinding(scope, descriptor.name) + if (binding?.kind !== 'mutable') continue + const write = { + position: node.end, + expression: descriptor.expression, + scope, + isSimple: descriptor.isSimple, + isUncertain: isUncertain || crossesNestedFunction(scope, binding), + } + binding.mutationPositions.push(write.position) + binding.writes.push(write) } }) } @@ -475,6 +596,91 @@ function evaluateConstantBindingValue(binding, resolving, depth) { return result } +function evaluateMutableBindingValue( + binding, + resolving, + referenceScope, + referencePosition, + depth, +) { + if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + + const writes = binding.writes ?? [] + if (writes.length > 1) return undefined + if (writes.length === 0) { + return evaluateMutableInitializerValue( + binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) + } + return evaluateMutableAssignmentValue( + binding, + writes[0], + resolving, + referenceScope, + referencePosition, + depth + 1, + ) +} + +function evaluateMutableInitializerValue( + binding, + resolving, + referenceScope, + referencePosition, + depth, +) { + if (!binding.initializer) return undefined + if (referencePosition < binding.initializer.getStart()) return undefined + if (!isBindingAvailable(binding, referencePosition)) return undefined + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantValue( + binding.initializer, + binding.scope, + nextResolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + return result ? { ...result, state: binding.initializer } : undefined +} + +function mutableAssignmentIsFoldable(binding, write, referencePosition) { + if (binding.initializer) return false + if (!write.isSimple || write.isUncertain) return false + if (!write.expression || write.position >= referencePosition) return false + return !expressionReferencesMutableBinding(write.expression, write.scope) +} + +function evaluateMutableAssignmentValue( + binding, + write, + resolving, + referenceScope, + referencePosition, + depth, +) { + // Rolldown only folds a mutable binding with a single, simple assignment + // when there was no initializer. Any explicit reassignment invalidates the + // binding's constant state, including writes after an earlier use. + if (!mutableAssignmentIsFoldable(binding, write, referencePosition)) return undefined + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantValue( + write.expression, + write.scope, + nextResolving, + referenceScope, + referencePosition, + undefined, + depth + 1, + ) + return result ? { ...result, state: write } : undefined +} + function mutableShadowBlocks(candidate, referencePosition, resolving, depth) { if (candidate.mutationPositions.some((position) => position <= referencePosition)) return true if (!candidate.initializer) return false @@ -545,6 +751,52 @@ function dependencyMatchesUseSite( return false } const visibleBinding = findBinding(referenceScope, dependency.name) + if (dependency.binding.kind === 'mutable') { + return mutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referenceScope, + referencePosition, + resolving, + depth + 1, + ) + } + return nonMutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referencePosition, + resolving, + depth + 1, + ) +} + +function mutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referenceScope, + referencePosition, + resolving, + depth, +) { + if (visibleBinding !== dependency.binding) return false + if (!isBindingAvailable(visibleBinding, referencePosition)) return false + const current = evaluateMutableBindingValue( + dependency.binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) + return current?.state === dependency.state && current.value === dependency.value +} + +function nonMutableDependencyMatchesUseSite( + dependency, + visibleBinding, + referencePosition, + resolving, + depth, +) { if ( !visibleBinding || visibleBinding === dependency.binding || @@ -566,17 +818,26 @@ function evaluateConstantBinding( const binding = findBinding(scope, expression.text) if ( !binding || - binding.kind !== 'constant' || + (binding.kind !== 'constant' && binding.kind !== 'mutable') || resolving.has(binding) || !isBindingAvailable(binding, expression.getStart()) ) { return undefined } - const value = evaluateConstantBindingValue(binding, resolving, depth + 1) + const value = + binding.kind === 'constant' + ? evaluateConstantBindingValue(binding, resolving, depth + 1) + : evaluateMutableBindingValue( + binding, + resolving, + referenceScope, + referencePosition, + depth + 1, + ) if (!value) return undefined const dependencies = [ - { name: expression.text, binding, value: value.value }, + { name: expression.text, binding, value: value.value, state: value.state }, ...value.dependencies, ] if ( @@ -854,25 +1115,60 @@ function possibleConditionalTarget(expression, context) { ) } +function expressionReferencesMutableBinding(expression, scope) { + let found = false + walkAst(expression, (node) => { + if (!isIdentifier(node)) return + const binding = findBinding(scope, node.text) + if (binding?.kind === 'mutable') found = true + }) + return found +} + function possibleIdentifierTarget(expression, context) { const { scope, resolving, referenceScope, referencePosition, depth } = context const binding = findBinding(scope, expression.text) - if ( - !binding || - binding.kind !== 'constant' || - resolving.has(binding) || - !isBindingAvailable(binding, expression.getStart()) - ) { + if (!identifierTargetBindingIsAvailable(binding, resolving, expression.getStart())) { return false } + if (binding.kind === 'mutable') { + return mutableIdentifierTarget(binding, context) + } + if (binding.kind !== 'constant') return false + return constantIdentifierTarget(expression, binding, context) +} + +function identifierTargetBindingIsAvailable(binding, resolving, referencePosition) { + return Boolean( + binding && + !resolving.has(binding) && + isBindingAvailable(binding, referencePosition), + ) +} + +function mutableIdentifierTarget(binding, context) { + const value = evaluateMutableBindingValue( + binding, + context.resolving, + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) + return value?.value === REACT_HOTKEYS_HOOK_MODULE +} + +function constantIdentifierTarget(expression, binding, context) { + const value = evaluateConstantBindingValue(binding, context.resolving, context.depth + 1) + if (value) return constantTargetDependenciesMatch(expression, binding, value, context) + if (expressionReferencesMutableBinding(binding.initializer, binding.scope)) return false const dependency = { name: expression.text, binding } if ( !dependencyMatchesUseSite( dependency, - referenceScope, - referencePosition, - resolving, - depth + 1, + context.referenceScope, + context.referencePosition, + context.resolving, + context.depth + 1, ) ) { return false @@ -880,10 +1176,27 @@ function possibleIdentifierTarget(expression, context) { return expressionMayResolveToReactHotkeys( binding.initializer, binding.scope, - new Set(resolving).add(binding), - referenceScope, - referencePosition, - depth + 1, + new Set(context.resolving).add(binding), + context.referenceScope, + context.referencePosition, + context.depth + 1, + ) +} + +function constantTargetDependenciesMatch(expression, binding, value, context) { + if (value.value !== REACT_HOTKEYS_HOOK_MODULE) return false + const dependencies = [ + { name: expression.text, binding, value: value.value }, + ...value.dependencies, + ] + return dependencies.every((dependency) => + dependencyMatchesUseSite( + dependency, + context.referenceScope, + context.referencePosition, + context.resolving, + context.depth + 1, + ), ) } diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index 94dfeaebf..d90c38330 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -16,6 +16,89 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { const pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: true, }, + { + name: 'function-local let initializer', + source: "export function load() { let pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local var initializer', + source: "export function load() { var pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local let simple assignment', + source: "export function load() { let pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'function-local var simple assignment', + source: "export function load() { var pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: true, + }, + { + name: 'mutable read before assignment', + source: "export function load() { let pkg; import(pkg); pkg = 'react-hotkeys-hook' }", + resolves: false, + }, + { + name: 'mutable reassignment before use', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; pkg = 'other'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable reassignment after use', + source: "export function load() { let pkg = 'react-hotkeys-hook'; import(pkg); pkg = 'other' }", + resolves: false, + }, + { + name: 'mutable branch write', + source: + "declare const enabled: boolean; export function load() { let pkg; if (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable loop write', + source: + "declare const enabled: boolean; export function load() { let pkg; while (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", + resolves: false, + }, + { + name: 'mutable unknown write', + source: + "declare function moduleName(): string; export function load() { let pkg = 'react-hotkeys-hook'; pkg = moduleName(); return import(pkg) }", + resolves: false, + }, + { + name: 'mutable nested block assignment', + source: "export function load() { let pkg; { pkg = 'react-hotkeys-hook' } return import(pkg) }", + resolves: true, + }, + { + name: 'mutable alias after assignment', + source: + "export function load() { let pkg; pkg = 'react-hotkeys-hook'; const alias = pkg; return import(alias) }", + resolves: true, + }, + { + name: 'mutable alias before assignment', + source: + "export function load() { let pkg; const alias = pkg; pkg = 'react-hotkeys-hook'; return import(alias) }", + resolves: false, + }, + { + name: 'mutable alias captured before later write', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; const alias = pkg; pkg = 'other'; return import(alias) }", + resolves: false, + }, + { + name: 'mutable closure uncertainty', + source: + "export function load() { let pkg = 'react-hotkeys-hook'; const inner = () => import(pkg); return inner }", + resolves: false, + }, { name: 'shadowed parameter', source: @@ -514,6 +597,72 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { return require('react-hotkeys-hook') }", resolves: true, }, + { + name: 'type-only named require binding', + source: + "import { type require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only default require binding', + source: + "import type require from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only namespace require binding', + source: + "import type * as require from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'type-only import does not shadow package binding', + source: + "const pkg = 'react-hotkeys-hook'; import type { pkg } from 'runtime-name'; import(pkg)", + resolves: true, + }, + { + name: 'mixed value import still shadows require', + source: + "import { type Other, require } from 'runtime-name'; export function load() { return require('react-hotkeys-hook') }", + resolves: false, + }, + { + name: 'ambient function require binding', + source: + "declare function require(id: string): unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient const require binding', + source: + "declare const require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient let require binding', + source: + "declare let require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient var require binding', + source: + "declare var require: (id: string) => unknown; export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient class require binding', + source: + "declare class require {} export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, + { + name: 'ambient namespace require binding', + source: + "declare namespace require {} export function load() { return require('react-hotkeys-hook') }", + resolves: true, + }, { name: 'require parameter shadow', source: From 3726994b77f154e57602c66b5f6efb818287fd79 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Thu, 27 Aug 2026 11:05:15 -0700 Subject: [PATCH 21/21] fix(hotkeys): match Rolldown control-flow folding --- scripts/runtime-hotkey-import-boundary.mjs | 140 ++++++++++++++---- ...ntime-hotkey-registration-coverage.test.ts | 53 +++++++ 2 files changed, 168 insertions(+), 25 deletions(-) diff --git a/scripts/runtime-hotkey-import-boundary.mjs b/scripts/runtime-hotkey-import-boundary.mjs index 7daceddad..a918af357 100644 --- a/scripts/runtime-hotkey-import-boundary.mjs +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -37,6 +37,12 @@ const BINARY_VALUE_RESOLVERS = new Map([ const UPDATE_OPERATORS = new Set([SyntaxKind.PlusPlusToken, SyntaxKind.MinusMinusToken]) +const SHORT_CIRCUIT_OPERATORS = new Set([ + SyntaxKind.AmpersandAmpersandToken, + SyntaxKind.BarBarToken, + SyntaxKind.QuestionQuestionToken, +]) + const FUNCTION_SCOPE_KINDS = new Set([ SyntaxKind.FunctionDeclaration, SyntaxKind.FunctionExpression, @@ -99,7 +105,15 @@ function createScope( owner, ) { const region = !parent || isConstantBoundary ? { bindings: new Map() } : parent.region - return { parent, kind, isVarScope, isConstantBoundary, region, bindings: new Map(), owner } + return { + parent, + kind, + isVarScope, + isConstantBoundary, + region, + bindings: new Map(), + owner: owner ?? parent?.owner, + } } function declareBinding(scope, name, binding) { @@ -164,8 +178,10 @@ function variableBinding(declaration, declarationScope, isConst, isResolvableCon scope: declarationScope, mutationPositions: [], writes: [], + owner: declarationScope.owner, isHoisted, availableAfter: isHoisted ? 0 : declaration.end, + assignmentAvailableAfter: declaration.end, } } return { kind: 'unknown-shadow', availableAfter: declaration.end } @@ -269,6 +285,7 @@ function createChildLexicalScope(node, currentScope) { 'block', BLOCK_VAR_SCOPE_KINDS.has(node.kind), node.kind === SyntaxKind.ModuleBlock, + node.kind === SyntaxKind.ModuleBlock ? node : undefined, ) } @@ -314,7 +331,7 @@ function predeclareNodeBindings(node, currentScope) { } function buildLexicalScopes(sourceFile) { - const sourceScope = createScope(undefined, 'source', true, true) + const sourceScope = createScope(undefined, 'source', true, true, sourceFile) const nodeScopes = new WeakMap() function visitLoopHeader(node, currentScope) { @@ -413,30 +430,85 @@ function findLexicalBinding(scope, name) { return undefined } -function hasUncertainWriteAncestor(node) { - let current = node.parent - while (current) { - if (UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind)) return true - if ( - isBinaryExpression(current) && - (current.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken || - current.operatorToken.kind === SyntaxKind.BarBarToken || - current.operatorToken.kind === SyntaxKind.QuestionQuestionToken) - ) { - return true - } - current = current.parent +function staticControlValue(expression, nodeScopes, sourceScope) { + const scope = nodeScopes.get(expression) ?? sourceScope + const result = evaluateConstantValue( + expression, + scope, + new Set(), + scope, + expression.getStart(), + ) + return result ? { known: true, value: result.value } : { known: false } +} + +function conditionalBranchStatus( + branch, + condition, + whenTrue, + whenFalse, + nodeScopes, + sourceScope, +) { + if (branch === condition) return 'reachable' + const control = staticControlValue(condition, nodeScopes, sourceScope) + if (!control.known) return 'uncertain' + return branch === (control.value ? whenTrue : whenFalse) ? 'reachable' : 'unreachable' +} + +function logicalRightStatus(node, expression, nodeScopes, sourceScope) { + if (node !== expression.right) return 'reachable' + const left = staticControlValue(expression.left, nodeScopes, sourceScope) + if (!left.known) return 'uncertain' + const executes = + expression.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken + ? Boolean(left.value) + : expression.operatorToken.kind === SyntaxKind.BarBarToken + ? !left.value + : left.value === null + return executes ? 'reachable' : 'unreachable' +} + +function controlFlowAncestorStatus(current, branch, nodeScopes, sourceScope) { + if (current.kind === SyntaxKind.IfStatement) { + return conditionalBranchStatus( + branch, + current.expression, + current.thenStatement, + current.elseStatement, + nodeScopes, + sourceScope, + ) } - return false + if (current.kind === SyntaxKind.ConditionalExpression) { + return conditionalBranchStatus( + branch, + current.condition, + current.whenTrue, + current.whenFalse, + nodeScopes, + sourceScope, + ) + } + if ( + isBinaryExpression(current) && + SHORT_CIRCUIT_OPERATORS.has(current.operatorToken.kind) + ) { + return logicalRightStatus(branch, current, nodeScopes, sourceScope) + } + return UNCERTAIN_WRITE_ANCESTOR_KINDS.has(current.kind) ? 'uncertain' : 'reachable' } -function crossesNestedFunction(scope, binding) { - let current = scope - while (current && current !== binding.scope) { - if (current.kind === 'function') return true +function controlFlowWriteStatus(node, owner, nodeScopes, sourceScope) { + let branch = node + let current = node.parent + while (current && current !== owner) { + const status = controlFlowAncestorStatus(current, branch, nodeScopes, sourceScope) + if (status !== 'reachable') return status + branch = current current = current.parent } - return current !== binding.scope + return current === owner ? 'reachable' : 'uncertain' } function assignmentWriteDescriptors(node) { @@ -481,25 +553,42 @@ function loopWriteDescriptors(node) { } function markMutableBindingWrites(sourceFile, nodeScopes, sourceScope) { + const writes = [] walkAst(sourceFile, (node) => { const descriptors = assignmentWriteDescriptors(node) if (descriptors.length === 0) return const scope = nodeScopes.get(node) ?? sourceScope - const isUncertain = hasUncertainWriteAncestor(node) for (const descriptor of descriptors) { const binding = findLexicalBinding(scope, descriptor.name) if (binding?.kind !== 'mutable') continue const write = { + node, + start: node.getStart(), position: node.end, expression: descriptor.expression, scope, isSimple: descriptor.isSimple, - isUncertain: isUncertain || crossesNestedFunction(scope, binding), + isReachable: true, + isUncertain: true, } - binding.mutationPositions.push(write.position) binding.writes.push(write) + writes.push({ binding, write }) } }) + + for (const { binding, write } of writes) { + const status = + scopeOwner(write.scope) === binding.owner + ? controlFlowWriteStatus(write.node, binding.owner, nodeScopes, sourceScope) + : 'uncertain' + write.isReachable = status !== 'unreachable' + write.isUncertain = status !== 'reachable' + if (write.isReachable) binding.mutationPositions.push(write.position) + } +} + +function scopeOwner(scope) { + return scope?.owner } function isBindingAvailable(binding, referencePosition) { @@ -605,7 +694,7 @@ function evaluateMutableBindingValue( ) { if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined - const writes = binding.writes ?? [] + const writes = (binding.writes ?? []).filter((write) => write.isReachable) if (writes.length > 1) return undefined if (writes.length === 0) { return evaluateMutableInitializerValue( @@ -651,6 +740,7 @@ function evaluateMutableInitializerValue( function mutableAssignmentIsFoldable(binding, write, referencePosition) { if (binding.initializer) return false + if (write.start < binding.assignmentAvailableAfter) return false if (!write.isSimple || write.isUncertain) return false if (!write.expression || write.position >= referencePosition) return false return !expressionReferencesMutableBinding(write.expression, write.scope) diff --git a/src/config/runtime-hotkey-registration-coverage.test.ts b/src/config/runtime-hotkey-registration-coverage.test.ts index d90c38330..ff98cc4db 100644 --- a/src/config/runtime-hotkey-registration-coverage.test.ts +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -36,6 +36,53 @@ const ROLLDOWN_PARITY_CASES = [ source: "export function load() { var pkg; pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: true, }, + { + name: 'function owner inside dynamic branch', + source: + "declare const enabled: boolean; if (enabled) { function load() { let pkg; pkg = 'react-hotkeys-hook'; return import(pkg) } load() }", + resolves: true, + }, + { + name: 'class owner inside dynamic branch', + source: + "declare const enabled: boolean; if (enabled) { class Loader { static { let pkg; pkg = 'react-hotkeys-hook'; import(pkg) } } new Loader() }", + resolves: true, + }, + { + name: 'mutable statically true branch write', + source: "let pkg; if (true) pkg = 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'mutable statically false else write', + source: "let pkg; if (false) pkg = 'other'; else pkg = 'react-hotkeys-hook'; import(pkg)", + resolves: true, + }, + { + name: 'mutable dead branch reassignment', + source: "let pkg = 'react-hotkeys-hook'; if (false) pkg = 'other'; import(pkg)", + resolves: true, + }, + { + name: 'mutable statically executed logical write', + source: "let pkg; true && (pkg = 'react-hotkeys-hook'); import(pkg)", + resolves: true, + }, + { + name: 'mutable statically selected conditional write', + source: "let pkg; true ? pkg = 'react-hotkeys-hook' : pkg = 'other'; import(pkg)", + resolves: true, + }, + { + name: 'var assignment before declaration', + source: "pkg = 'react-hotkeys-hook'; var pkg; import(pkg)", + resolves: false, + }, + { + name: 'let assignment before declaration', + source: "pkg = 'react-hotkeys-hook'; let pkg; import(pkg)", + resolves: false, + }, { name: 'mutable read before assignment', source: "export function load() { let pkg; import(pkg); pkg = 'react-hotkeys-hook' }", @@ -64,6 +111,12 @@ const ROLLDOWN_PARITY_CASES = [ "declare const enabled: boolean; export function load() { let pkg; while (enabled) pkg = 'react-hotkeys-hook'; return import(pkg) }", resolves: false, }, + { + name: 'mutable exception-path write', + source: + "export function load() { let pkg; try { pkg = 'react-hotkeys-hook' } finally {} return import(pkg) }", + resolves: false, + }, { name: 'mutable unknown write', source: