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/scripts/runtime-hotkey-import-boundary.d.mts b/scripts/runtime-hotkey-import-boundary.d.mts new file mode 100644 index 000000000..8fb226982 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.d.mts @@ -0,0 +1,19 @@ +export interface RuntimeHotkeyBoundarySource { + path: string + source: string +} + +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' + +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..a918af357 --- /dev/null +++ b/scripts/runtime-hotkey-import-boundary.mjs @@ -0,0 +1,1533 @@ +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, + isBinaryExpression, + isCallExpression, + isElementAccessExpression, + isEnumDeclaration, + isExportDeclaration, + isExternalModuleReference, + isIdentifier, + isImportDeclaration, + isImportEqualsDeclaration, + isNoSubstitutionTemplateLiteral, + isNumericLiteral, + isPostfixUnaryExpression, + isPrefixUnaryExpression, + isPropertyAccessExpression, + 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' + +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 SHORT_CIRCUIT_OPERATORS = new Set([ + SyntaxKind.AmpersandAmpersandToken, + SyntaxKind.BarBarToken, + SyntaxKind.QuestionQuestionToken, +]) + +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, + SyntaxKind.WhileStatement, + SyntaxKind.DoStatement, +]) + +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, +]) + +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(), + owner: owner ?? parent?.owner, + } +} + +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)) { + 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 isAmbientDeclaration(node) { + let current = node + while (current) { + if (hasModifier(current, SyntaxKind.DeclareKeyword)) return true + current = current.parent + } + return false +} + +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, binding = { kind: 'barrier' }) { + for (const identifier of bindingNames(name)) { + declareBinding(scope, identifier, binding) + } +} + +function nearestVarScope(scope) { + let current = scope + while (current.parent && !current.isVarScope) current = current.parent + return current +} + +function variableBinding(declaration, declarationScope, isConst, isResolvableConst, isHoisted) { + 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: [], + writes: [], + owner: declarationScope.owner, + isHoisted, + availableAfter: isHoisted ? 0 : declaration.end, + assignmentAvailableAfter: 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) + 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, + !isBlockScoped, + ) + if (isIdentifier(declaration.name)) { + declareBinding(declarationScope, declaration.name.text, binding) + } else { + declareBarrier(declarationScope, declaration.name, binding) + } + } +} + +function declareImportBindings(node, scope) { + 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) { + if (!element.isTypeOnly) declareBarrier(scope, element.name) + } +} + +function createFunctionLexicalScope(node, currentScope) { + if ( + node.kind === SyntaxKind.FunctionDeclaration && + node.name && + !isAmbientDeclaration(node) + ) { + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) + } + + const functionScope = createScope(currentScope, 'function', true, true, node) + 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 && !isAmbientDeclaration(node)) { + declareBarrier(currentScope, node.name, { kind: 'static-shadow' }) + } + + const classScope = createScope(currentScope, 'class', false, true, node) + 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', false, true) + if (node.variableDeclaration) declareBarrier(catchScope, node.variableDeclaration.name) + return catchScope + } + 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, + node.kind === SyntaxKind.ModuleBlock ? node : undefined, + ) +} + +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) return declareVariableList(node, currentScope) + if (isImportDeclaration(node) || isImportEqualsDeclaration(node)) { + return declareImportBindings(node, currentScope) + } + if (isConstEnumDeclaration(node) && !isAmbientDeclaration(node)) { + return declareConstEnum(node, currentScope) + } + if ( + BARRIER_DECLARATION_KINDS.has(node.kind) && + node.name && + !isAmbientDeclaration(node) + ) { + declareBarrier(currentScope, node.name) + } +} + +function buildLexicalScopes(sourceFile) { + const sourceScope = createScope(undefined, 'source', true, true, sourceFile) + 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) { + 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) + } + 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) { + 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) + } + 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)) + } + + visit(sourceFile, sourceScope) + markMutableBindingWrites(sourceFile, nodeScopes, 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 ordinary lexical blocks, but not through + // captured, catch, repeated-loop, or namespace environments. + if (current.isConstantBoundary) return undefined + current = current.parent + } + 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 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, + ) + } + 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 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 === owner ? 'reachable' : 'uncertain' +} + +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) { + const writes = [] + walkAst(sourceFile, (node) => { + const descriptors = assignmentWriteDescriptors(node) + if (descriptors.length === 0) return + const scope = nodeScopes.get(node) ?? sourceScope + 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, + isReachable: true, + isUncertain: true, + } + 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) { + return binding.availableAfter === undefined || referencePosition >= binding.availableAfter +} + +function evaluateTemplateValue( + expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth, +) { + let value = expression.head.text + const dependencies = [] + for (const span of expression.templateSpans) { + const interpolation = evaluateConstantValue( + span.expression, + scope, + resolving, + referenceScope, + referencePosition, + enumBinding, + depth + 1, + ) + if (!interpolation) return undefined + value += String(interpolation.value) + span.literal.text + dependencies.push(...interpolation.dependencies) + } + return { value, dependencies } +} + +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: resolver(left.value, right.value), + dependencies: [...left.dependencies, ...right.dependencies], + } +} + +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) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + + const nextResolving = new Set(resolving).add(binding) + const result = evaluateConstantValue( + binding.initializer, + binding.scope, + nextResolving, + binding.scope, + binding.initializer.getStart(), + undefined, + depth + 1, + ) + binding.cachedValue = result ?? null + return result +} + +function evaluateMutableBindingValue( + binding, + resolving, + referenceScope, + referencePosition, + depth, +) { + if (resolving.has(binding) || depth > MAX_CONSTANT_EVALUATION_DEPTH) return undefined + + const writes = (binding.writes ?? []).filter((write) => write.isReachable) + 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.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) +} + +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 + 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 (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 || + !isBindingAvailable(visibleBinding, referencePosition) + ) { + return true + } + return visibleBindingAllowsCapture(visibleBinding, referencePosition, resolving, depth + 1) +} + +function evaluateConstantBinding( + expression, + scope, + resolving, + referenceScope, + referencePosition, + depth, +) { + const binding = findBinding(scope, expression.text) + if ( + !binding || + (binding.kind !== 'constant' && binding.kind !== 'mutable') || + resolving.has(binding) || + !isBindingAvailable(binding, expression.getStart()) + ) { + return undefined + } + + 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, state: value.state }, + ...value.dependencies, + ] + if ( + !dependencies.every((dependency) => + dependencyMatchesUseSite( + dependency, + referenceScope, + referencePosition, + resolving, + depth + 1, + ), + ) + ) { + return undefined + } + return { value: value.value, dependencies } +} + +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 || 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 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 (!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, + context.referenceScope, + context.referencePosition, + context.resolving, + context.depth + 1, + ) + ) { + return false + } + return expressionMayResolveToReactHotkeys( + binding.initializer, + binding.scope, + 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, + ), + ) +} + +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 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) { + const runtimeDeclaration = isImportDeclaration(node) + ? isRuntimeImportDeclaration(node) + : isExportDeclaration(node) && isRuntimeExportDeclaration(node) + return runtimeDeclaration && isReactHotkeysSource(node.moduleSpecifier, scope) +} + +function isTypeScriptReactHotkeysImport(node, scope) { + if (!isImportEqualsDeclaration(node) || node.isTypeOnly) return false + const reference = node.moduleReference + return isExternalModuleReference(reference) && isReactHotkeysSource(reference.expression, scope) +} + +function isReactHotkeysCallImport(node, scope) { + if (!isCallExpression(node)) return false + const { expression, arguments: args } = node + const isRequire = + isIdentifier(expression) && + expression.text === 'require' && + !findLexicalBinding(scope, expression.text) + const isDynamicImport = expression.kind === SyntaxKind.ImportKeyword + return ( + (isRequire || isDynamicImport) && + args.length === 1 && + isReactHotkeysSource(args[0], scope) + ) +} + +const IMPORT_NODE_CHECKS = [ + isStaticReactHotkeysImport, + isTypeScriptReactHotkeysImport, + isReactHotkeysCallImport, +] + +function walkAst(node, onNode) { + onNode(node) + node.forEachChild((child) => walkAst(child, onNode)) +} + +export function findReactHotkeysHookImportViolations( + sources, + allowedPath = RUNTIME_HOTKEY_ADAPTER_PATH, +) { + const violations = [] + if (sources.length === 0) return violations + + const virtualRoot = '/runtime-hotkey-import-boundary' + const virtualSources = new Map() + const virtualFiles = Object.fromEntries( + sources.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()], + }) + + const compiler = new API({ cwd: virtualRoot, fs: createVirtualFileSystem(virtualFiles) }) + let snapshot + + 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) + if (!sourceFile) throw new Error(`TypeScript could not parse in-memory source: ${path}`) + const { nodeScopes, sourceScope } = buildLexicalScopes(sourceFile) + + 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, + column, + allowedPath, + message: `${path}:${line}:${column} imports ${REACT_HOTKEYS_HOOK_MODULE}; use ${allowedPath}`, + }) + } + + walkAst(sourceFile, (node) => { + const scope = nodeScopes.get(node) ?? sourceScope + if (IMPORT_NODE_CHECKS.some((check) => check(node, scope))) record(node) + }) + } + } finally { + snapshot?.dispose() + compiler.close() + } + + return violations.sort( + (left, right) => + 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)) { + try { + runCli() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts new file mode 100644 index 000000000..8419e5721 --- /dev/null +++ b/src/config/hotkeys-dom-guard.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom + +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' +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'), + ) +} + +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() + }) + + 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.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( + '
', + '#control', + 'j', + ) + + 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 + 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) + }) + + 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) + }) + + 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 53dec865a..63a67638b 100644 --- a/src/config/hotkeys.test.ts +++ b/src/config/hotkeys.test.ts @@ -1,24 +1,28 @@ // @vitest-environment node -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from 'vite-plus/test' import { HOTKEYS, HOTKEY_EXPORT_SCHEMA, HOTKEY_EXPORT_VERSION, createHotkeyExportDocument, + doesHotkeyEventMatchBinding, findHotkeyConflicts, formatHotkeyBinding, getBrowserHostileHotkey, getHotkeyBindingFromEventData, getHotkeyPrimaryTokenFromEventData, + getRuntimeHotkeyBinding, normalizeHotkeyBinding, parseHotkeyImportDocument, + resolveHotkeyConfiguration, resolveHotkeys, + resolveRuntimeHotkeys, 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 +31,625 @@ 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", - }); - }); -}); - -describe("normalizeHotkeyBinding", () => { - it("orders modifiers consistently and normalizes aliases", () => { - expect(normalizeHotkeyBinding("Shift+Ctrl+ArrowLeft")).toBe( - "mod+shift+left", - ); - }); -}); - -describe("formatHotkeyBinding", () => { - it("formats modifier labels for mac", () => { - expect(formatHotkeyBinding("mod+alt+k", "MacIntel")).toBe( - "Cmd + Option + K", - ); - }); - - it("formats punctuation bindings for windows", () => { - expect(formatHotkeyBinding("mod+shift+comma", "Win32")).toBe( - "Ctrl + Shift + ,", - ); - }); -}); - -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("returns null for browser-safe shortcuts", () => { - expect(getBrowserHostileHotkey("shift+j")).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", - }); - }); - - 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", () => { + split: '3', + addInEdit: 'shift+k', + previous: 'alt+bracketleft', + next: 'alt+bracketright', + auto: 'a', + fit: 'f', + }) + }) +}) + +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('normalizeHotkeyBinding', () => { + it('orders modifiers consistently and normalizes aliases', () => { + 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') + }) +}) + +describe('formatHotkeyBinding', () => { + it('formats modifier labels for mac', () => { + expect(formatHotkeyBinding('mod+alt+k', 'MacIntel')).toBe('Cmd + Option + K') + }) + + 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', () => { + 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('returns null for browser-safe shortcuts', () => { + expect(getBrowserHostileHotkey('shift+j')).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', + }) + }) + + 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('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({ - SELECTION_TOOL: "c", - }); + SELECTION_TOOL: 'c', + }) + + 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') + }) + + 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', () => { + 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([]) + }) + + 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('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('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('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(), + 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', + SHUTTLE_REVERSE: 'g', + }) - expect(findHotkeyConflicts(bindings, "c", "SELECTION_TOOL")).toEqual([ - "RAZOR_TOOL", - ]); - }); -}); + expect(result.overrides).toEqual({ MARK_IN: 'q', SHUTTLE_REVERSE: 'g' }) + expect(result.warnings).toEqual([]) + }) +}) -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: "", - }); - }); -}); - -describe("createHotkeyExportDocument", () => { - it("creates a versioned export with command metadata and sanitized overrides", () => { + PLAY_PAUSE: 'shift+space', + EXPORT: 'ctrl+e', + DELETE_SELECTED: '', + }) + }) + + 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: 'ctrl+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: "EXPORT", - binding: "mod+e", - defaultBinding: "mod+shift+e", + id: 'SHUTTLE_PAUSE', + binding: 'k', + defaultBinding: 'k', + }), + ) + expect(exportDocument.commands).toContainEqual( + expect.objectContaining({ + id: 'EXPORT', + binding: 'ctrl+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: 'ctrl+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: 'ctrl+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: 'ctrl+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('falls back a v2 plain-K keyframe override that conflicts with transport', () => { + expect( + parseHotkeyImportDocument({ + schema: HOTKEY_EXPORT_SCHEMA, + version: 2, + overrides: { + EDIT_KEYFRAME_ADD: 'k', + }, + }), + ).toEqual({ + 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 050724cd4..976db12ef 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -7,273 +7,323 @@ 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; +const HOTKEY_COMMAND_ORDER = Object.keys(HOTKEYS) as HotkeyKey[] + +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 + 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[] +} + +type RuntimeHotkeyVariant = 'primary' | 'preview' + +interface RuntimeHotkeyClaim { + command: HotkeyKey + binding: string + variant: RuntimeHotkeyVariant +} + +interface RuntimePhysicalHotkeyClaim extends RuntimeHotkeyClaim { + physicalBinding: string } 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', '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", - option: "alt", - return: "enter", - esc: "escape", - del: "delete", - "=": "equal", - equals: "equal", - "-": "minus", - arrowleft: "left", - arrowright: "right", - arrowup: "up", - arrowdown: "down", -}; + cmd: 'meta', + command: 'meta', + control: 'ctrl', + 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_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", - 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 +332,609 @@ 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 = {}, +export function resolveHotkeyConfiguration(overrides: unknown = {}): HotkeyResolution { + const requested = sanitizeHotkeyOverrides(overrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] + const rejectedOverrides = new Set() + let bindings = createResolvedHotkeyBindings(commandKeys, requested, rejectedOverrides) + 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) { + const conflicts = getDuplicateRuntimeHotkeyGroups(bindings) + if (conflicts.length === 0) break + + 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) + } + + return { bindings, overrides: getEffectiveHotkeyOverrides(bindings), warnings } +} + +function createResolvedHotkeyBindings( + commandKeys: HotkeyKey[], + requested: HotkeyOverrideMap, + rejected: Set, ): HotkeyBindingMap { - return { - ...HOTKEYS, - ...sanitizeHotkeyOverrides(overrides), - }; + return Object.fromEntries( + commandKeys.map((key) => [ + key, + !rejected.has(key) && key in requested ? requested[key]! : HOTKEYS[key], + ]), + ) as HotkeyBindingMap +} + +function getDuplicateRuntimeHotkeyGroups(bindings: HotkeyBindingMap): RuntimeHotkeyClaim[][] { + 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( + conflicts: RuntimeHotkeyClaim[][], + requested: HotkeyOverrideMap, + rejected: Set, +): HotkeyConflictWarning[] { + return conflicts.flatMap((claims) => { + const commands = [...new Set(claims.map((claim) => claim.command))] + return commands + .filter((key) => !rejected.has(key) && key in requested && requested[key] !== HOTKEYS[key]) + .map((key) => ({ + code: 'duplicate_binding' as const, + command: key, + binding: claims.find((claim) => claim.command === key)!.binding, + 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 { + return resolveHotkeyConfiguration(overrides).bindings } 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 === "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]; + 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; + 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 { - 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('+')) +} + +/** 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) + const key = tokens.pop() + if (!key) return '' + return normalizeHotkeyBinding([...tokens, 'shift', key].join('+')) +} + +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 +} - return normalizeHotkeyBinding(tokens.join("+")); +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('+'))}` + }) } -function getHotkeyConflictMap( +/** + * Canonical graph of every physical chord registered at runtime, including + * 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, - ][]) { - const normalizedBinding = normalizeHotkeyBinding(binding); - if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - continue; +): Record { + const conflicts: Record = {} + + 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] ?? [] + bindingClaims.push({ ...claim, physicalBinding }) + conflicts[physicalBinding] = bindingClaims + } } + } + + return conflicts +} - conflicts[normalizedBinding] ??= []; - conflicts[normalizedBinding].push(key); +/** + * 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 conflicts; + return owned +} + +function getOwnedRuntimeHotkeyBinding( + graph: Record, + bindings: HotkeyBindingMap, + command: HotkeyKey, + variant: RuntimeHotkeyVariant, +): string | null { + const claim = getCommandRuntimeHotkeyClaims(command, bindings[command]).find( + (candidate) => candidate.variant === variant, + ) + if (!claim) return null + + const ownsEveryPhysicalBinding = getPhysicalHotkeyBindings(claim.binding).every((binding) => { + const owner = graph[binding]?.[0] + return owner?.command === command && owner.variant === variant + }) + 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 = getOwnedRuntimeHotkeyConflictGraph(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 = getOwnedRuntimeHotkeyConflictGraph(bindings) + return getOwnedRuntimeHotkeyBinding(graph, bindings, command, variant) } export function findHotkeyConflicts( @@ -709,22 +942,41 @@ export function findHotkeyConflicts( binding: string, currentKey?: HotkeyKey, ): HotkeyKey[] { - const normalizedBinding = normalizeHotkeyBinding(binding); + const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding || !hasHotkeyPrimaryToken(normalizedBinding)) { - return []; + return [] + } + + if (!currentKey) { + const graph = getRuntimeHotkeyConflictGraph(bindings) + return [ + ...new Set( + getPhysicalHotkeyBindings(normalizedBinding).flatMap((physicalBinding) => + (graph[physicalBinding] ?? []).map((claim) => claim.command), + ), + ), + ] } - return (getHotkeyConflictMap(bindings)[normalizedBinding] ?? []).filter( - (key) => key !== currentKey, - ); + const candidateBindings = { ...bindings, [currentKey]: normalizedBinding } + const graph = getRuntimeHotkeyConflictGraph(candidateBindings) + const conflicts = new Set() + for (const claim of getCommandRuntimeHotkeyClaims(currentKey, normalizedBinding)) { + 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)) } export function createHotkeyExportDocument( overrides: HotkeyOverrideMap = {}, ): HotkeyExportDocument { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides); - const bindings = resolveHotkeys(normalizedOverrides); - const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[]; + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides + const bindings = resolveHotkeys(normalizedOverrides) + const commandKeys = Object.keys(HOTKEYS) as HotkeyKey[] return { schema: HOTKEY_EXPORT_SCHEMA, @@ -738,23 +990,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 +1017,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,91 +1063,184 @@ function collectImportedOverrides(source: unknown): HotkeyImportResult { 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 { + 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 { + ...resultWithoutWarnings, + overrides, + ...(conflictWarnings && conflictWarnings.length > 0 ? { conflictWarnings } : {}), + } + } + + 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(resolveHotkeyImportResult(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 { - overrides: sanitizeHotkeyOverrides(importedOverrides), + const resolution = resolveHotkeyConfiguration(importedOverrides) + return migrateLegacyHotkeyImport({ + overrides: resolution.overrides, importedCommandCount, ignoredCommandCount, remappedCommandCount, sourceVersion, - }; + ...(resolution.warnings.length > 0 ? { conflictWarnings: resolution.warnings } : {}), + }) +} + +const GLOBAL_HOTKEY_OPT_IN = '[data-global-hotkeys="allow"]' +const DIALOG_SELECTOR = '[role="dialog"], dialog' +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 { + 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 +} + +/** + * 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 + if (target.closest(INTERACTIVE_CONTROL_SELECTOR)) return true + return target.closest(DIALOG_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, + // 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; +} as const 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..ff98cc4db --- /dev/null +++ b/src/config/runtime-hotkey-registration-coverage.test.ts @@ -0,0 +1,1074 @@ +// @vitest-environment node + +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 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: '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: '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' }", + 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 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: + "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: + "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: '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: + "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: '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: + "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, + }, + { + 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: '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: + "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 = ` + 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( + /(?:from\\s+|import\\s*\\(|import\\s+|__require\\s*\\()\\s*["']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() + const result = spawnSync(process.execPath, [BOUNDARY_SCRIPT], { + cwd: process.cwd(), + encoding: 'utf8', + }) + const elapsedMs = performance.now() - startedAt + + 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'"], + ['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')"], + ['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`, + 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('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) + + const mismatches: string[] = [] + 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, + ) + if (checkerResolves !== rolldownResolves) { + mismatches.push(`${fixture.name}: checker=${checkerResolves}, Rolldown=${rolldownResolves}`) + } + } + expect(mismatches).toEqual([]) + }) + + 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') + const documentation = "require('react-hotkeys-hook')" + const moduleName = getModuleName() + const hooks = await import(moduleName) + ` + + expect( + findReactHotkeysHookImportViolations([{ path: 'src/features/documentation.ts', source }]), + ).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')" + + 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( + findReactHotkeysHookImportViolations([{ path: RUNTIME_HOTKEY_ADAPTER_PATH, source }]), + ).toEqual([]) + const wrapperPath = 'src/hooks/use-hotkey-registration-wrapper.ts' + expect( + findReactHotkeysHookImportViolations([ + { path: RUNTIME_HOTKEY_ADAPTER_PATH, source }, + { path: wrapperPath, source }, + ]), + ).toEqual([ + expect.objectContaining({ + path: wrapperPath, + allowedPath: RUNTIME_HOTKEY_ADAPTER_PATH, + message: expect.stringContaining(RUNTIME_HOTKEY_ADAPTER_PATH), + }), + ]) + }) +}) 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/hooks/use-editor-hotkeys.ts b/src/features/editor/hooks/use-editor-hotkeys.ts index aeb6b5ad7..e84395c61 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 { useCommandHotkey } from '@/hooks/use-hotkey-registration' import { HOTKEY_OPTIONS } from '@/config/hotkeys' -import { useResolvedHotkeys } from '@/features/editor/deps/settings' import { useEditorStore } from '@/shared/state/editor' import { useSceneBrowserStore } from '@/features/editor/deps/scene-browser' @@ -24,12 +23,11 @@ interface EditorHotkeyCallbacks { * Uses react-hotkeys-hook with granular Zustand selectors */ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { - const hotkeys = useResolvedHotkeys() const enableLocalUi = callbacks.enableLocalUi ?? true // Save: Cmd/Ctrl+S - useHotkeys( - hotkeys.SAVE, + useCommandHotkey( + 'SAVE', (event) => { event.preventDefault() if (callbacks.onSave) { @@ -41,8 +39,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { ) // Export: Cmd/Ctrl+Shift+E - useHotkeys( - hotkeys.EXPORT, + useCommandHotkey( + 'EXPORT', (event) => { event.preventDefault() if (callbacks.onExport) { @@ -56,8 +54,8 @@ 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( - hotkeys.OPEN_SCENE_BROWSER, + useCommandHotkey( + 'OPEN_SCENE_BROWSER', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -69,8 +67,8 @@ 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( - hotkeys.WORKSPACE_EDIT, + useCommandHotkey( + 'WORKSPACE_EDIT', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -80,8 +78,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( - hotkeys.WORKSPACE_COLOR, + useCommandHotkey( + 'WORKSPACE_COLOR', (event) => { if (!enableLocalUi) return event.preventDefault() @@ -91,8 +89,8 @@ export function useEditorHotkeys(callbacks: EditorHotkeyCallbacks = {}) { [enableLocalUi], ) - useHotkeys( - hotkeys.WORKSPACE_ANIMATE, + useCommandHotkey( + 'WORKSPACE_ANIMATE', (event) => { if (!enableLocalUi) return event.preventDefault() 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..4019e7483 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,42 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { useEffect(() => { let cancelled = false + let unmountShortcutSettings: (() => void) | undefined + const shortcutSettingsAbortController = new AbortController() setState(null) setError(null) - void Promise.all([Promise.resolve(host.load()), i18nReady]) - .then(([snapshot]) => { - if (cancelled) return + + const initialize = async () => { + unmountShortcutSettings = await mountHostShortcutSettings( + host, + shortcutSettingsAbortController.signal, + ) + 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))) + shortcutSettingsAbortController.abort() + unmountShortcutSettings?.() + unmountShortcutSettings = undefined + if (cancelled) return + setError(caught instanceof Error ? caught : new Error(String(caught))) }) + return () => { cancelled = true + shortcutSettingsAbortController.abort() + 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..0db8de565 --- /dev/null +++ b/src/features/editor/host/shortcut-settings.test.ts @@ -0,0 +1,541 @@ +// @vitest-environment jsdom + +import { createElement } from 'react' +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' +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' +import { createHostShortcutSettings, type EditorHost, type HostShortcutSettings } from './contract' +import { HOST_SHORTCUT_RETRY_DELAYS_MS, mountHostShortcutSettings } from './shortcut-settings' + +function HostShortcutHarness() { + useHostTimelineShortcuts() + 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() + 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, + listenerCount: () => listeners.size, + emit: (settings: HostShortcutSettings) => { + for (const listener of listeners) listener(settings) + }, + } +} + +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 } +} + +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() + usePlaybackStore.setState({ + isPlaying: false, + playbackRate: 1, + transportMode: 'normal', + }) + }) + + 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( + 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: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }), + ) + + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + SHUTTLE_REVERSE: 'q', + SHUTTLE_PAUSE: 'w', + SHUTTLE_FORWARD: 'e', + }) + expect(harness.notify).not.toHaveBeenCalled() + + unmount() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ + 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('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) + 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: 'w' })) + await Promise.resolve() + expect(host.setSettings).not.toHaveBeenCalled() + expect(useSettingsStore.getState().hotkeyOverrides).toEqual({ SHUTTLE_PAUSE: 'w' }) + 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('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() + 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', 'f10') + 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) + expect(host.listenerCount()).toBe(1) + unmount() + expect(host.listenerCount()).toBe(0) + }) + + it('resolves a host collision so capture and bubbling listeners fire one intended action', async () => { + const harness = createShortcutHost( + createHostShortcutSettings({ + 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() + }) + + 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() + }) + + 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 new file mode 100644 index 000000000..703a6fed6 --- /dev/null +++ b/src/features/editor/host/shortcut-settings.ts @@ -0,0 +1,277 @@ +import { + resolveHotkeyConfiguration, + type HotkeyConflictWarning, + 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' + +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[] +} { + if (settings.schema !== HOST_SHORTCUTS_SCHEMA || settings.version !== HOST_SHORTCUTS_VERSION) { + throw new Error('Unsupported host shortcut settings schema') + } + + const resolution = resolveHotkeyConfiguration(settings.overrides) + return { + settings: createHostShortcutSettings(resolution.overrides), + warnings: resolution.warnings, + } +} + +function copyOverrides(overrides: HotkeyOverrideMap): HotkeyOverrideMap { + return { ...overrides } +} + +interface ShortcutOwnership { + epoch: number + standaloneOverrides: HotkeyOverrideMap + dispose?: () => void +} + +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. + */ +export async function mountHostShortcutSettings( + host: EditorHost, + signal?: AbortSignal, + retryScheduler: HostShortcutRetryScheduler = DEFAULT_HOST_SHORTCUT_RETRY_SCHEDULER, +): Promise<() => void> { + const previousOwnership = currentOwnership + const standaloneOverrides = copyOverrides( + previousOwnership?.standaloneOverrides ?? useSettingsStore.getState().hotkeyOverrides, + ) + previousOwnership?.dispose?.() + const ownership: ShortcutOwnership = { + epoch: ++nextOwnershipEpoch, + standaloneOverrides, + } + currentOwnership = ownership + let applyingHostSettings = false + 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) + if (currentOwnership?.epoch !== ownership.epoch) return + currentOwnership = null + useSettingsStore.getState().replaceHotkeyOverrides(ownership.standaloneOverrides) + } + ownership.dispose = dispose + + 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 }) + } + + 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 + 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 + if (inFlightSettings || !desiredSettings) return false + if (reconcileAfterFlight || !settledSettings) return true + 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 + retryAttempt = 0 + } + const desiredChanged = desiredDiffersFrom(settingsToWrite) + inFlightSettings = null + if (desiredChanged || reconcileAfterFlight) { + scheduleReconcile() + return + } + if (!succeeded && hasUnsettledDesiredSettings()) { + scheduleRetry() + } + } + + 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) + } + + 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) + if (normalized.warnings.length > 0) { + for (const warning of normalized.warnings) { + host.notify?.({ + kind: 'conflict', + message: `Shortcut ${warning.binding} for ${warning.command} conflicts with ${warning.conflictingCommand}; retained the last valid shortcut settings.`, + }) + } + return + } + applyingHostSettings = true + try { + useSettingsStore.getState().replaceHotkeyOverrides(normalized.settings.overrides) + } finally { + 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) + } + } + + let initialSettings: HostShortcutSettings + try { + initialSettings = await Promise.resolve(port.getSettings()) + } catch (error) { + dispose() + throw error + } + if (!isCurrent()) { + return dispose + } + desiredSettings = createHostShortcutSettings( + copyOverrides(useSettingsStore.getState().hotkeyOverrides), + ) + settledSettings = initialSettings + applyHostSettings(initialSettings) + + unsubscribeHost = port.subscribe?.((settings) => { + if (!isCurrent()) return + try { + applyHostSettings(settings) + } catch { + reportFailure('Could not apply keyboard shortcuts from the host.') + } + }) + + unsubscribeStore = useSettingsStore.subscribe((state, previousState) => { + if ( + disposed || + applyingHostSettings || + state.hotkeyOverrides === previousState.hotkeyOverrides + ) { + return + } + + const settings = createHostShortcutSettings(copyOverrides(state.hotkeyOverrides)) + desiredSettings = settings + cancelRetry(true) + scheduleReconcile() + }) + + return dispose +} diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx index 57b347873..e91e5e375 100644 --- a/src/features/keyframes/components/dopesheet-editor/index.tsx +++ b/src/features/keyframes/components/dopesheet-editor/index.tsx @@ -17,7 +17,7 @@ import { } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' -import { useHotkeys } from 'react-hotkeys-hook' +import { useCommandHotkey, useLocalHotkey } from '@/hooks/use-hotkey-registration' import { ChevronDown, ChevronLeft, @@ -477,14 +477,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 +905,6 @@ export const DopesheetEditor = memo(function DopesheetEditor({ showPlayhead = true, shortcutsEnabled = false, addKeyframeShortcutEnabled = false, - shortcuts, className, }: DopesheetEditorProps) { perfMarkRender('DopesheetEditor') @@ -1583,13 +1574,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 +1677,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 +1928,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 +2537,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ? propertyRowByProperty.get(selectedProperty) : undefined - useHotkeys( - shortcuts?.addKeyframe ?? '', + useCommandHotkey( + 'EDIT_KEYFRAME_ADD', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2571,9 +2551,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 +2562,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ ], ) - useHotkeys( - shortcuts?.previousKeyframe ?? '', + useCommandHotkey( + 'KEYFRAME_PREVIOUS', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2594,14 +2572,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( + 'KEYFRAME_NEXT', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2610,14 +2587,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( + 'KEYFRAME_TOGGLE_AUTO', (event) => { event.preventDefault() if (activePropertyRow) { @@ -2626,29 +2602,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( + '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 +2632,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs, onRemoveKeyframes], ) - useHotkeys( - 'left', + useLocalHotkey( + 'DOPESHEET_NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedKeyframes(-1) @@ -2669,8 +2642,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({ [disabled, selectedRefs.length, nudgeSelectedKeyframes], ) - useHotkeys( - 'right', + useLocalHotkey( + 'DOPESHEET_NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedKeyframes(1) @@ -2679,8 +2652,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 +2662,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 f3b1f2b92..96528bb66 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( { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled - shortcuts={{ - addKeyframe: "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( { onAddKeyframe={onAddKeyframe} onRemoveKeyframes={onRemoveKeyframes} shortcutsEnabled - shortcuts={{ - addKeyframe: "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( { height={240} onAddKeyframe={onAddKeyframe} shortcutsEnabled={false} - shortcuts={{ - addKeyframe: "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( { onNavigateToKeyframe={onNavigateToKeyframe} shortcutsEnabled={false} addKeyframeShortcutEnabled - shortcuts={{ - addKeyframe: "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() + }) + + it('does not add a keyframe on plain K', () => { + const onAddKeyframe = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'k', code: 'KeyK' }) - expect(onAddKeyframe).toHaveBeenCalledWith("x", 24); - expect(onNavigateToKeyframe).not.toHaveBeenCalled(); - }); -}); + expect(onAddKeyframe).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx index dbd2d8468..56863e3f3 100644 --- a/src/features/preview/components/source-monitor.test.tsx +++ b/src/features/preview/components/source-monitor.test.tsx @@ -63,6 +63,30 @@ 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', + }, +})) + +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}, @@ -133,7 +157,11 @@ vi.mock('@/features/preview/deps/settings', () => { { getState: () => settingsState }, ) - return { useSettingsStore } + return { + useSettingsStore, + useResolvedHotkeys: () => resolvedHotkeysState.hotkeys, + useRuntimeHotkeys: () => runtimeHotkeysState.hotkeys, + } }) vi.mock('@/shared/state/editor', () => { @@ -195,6 +223,99 @@ 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', + } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } + }) + + 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', + } + runtimeHotkeysState.hotkeys = { ...resolvedHotkeysState.hotkeys } + 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('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()) + 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' }) + 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..ad6cc10ea 100644 --- a/src/features/preview/components/source-monitor.tsx +++ b/src/features/preview/components/source-monitor.tsx @@ -70,6 +70,9 @@ import { 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 { useCommandHotkeyBinding } from '@/hooks/use-hotkey-registration' +import { useResolvedHotkeys } from '@/features/preview/deps/settings' interface SourceMonitorProps { mediaId: string @@ -205,6 +208,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,7 +257,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)) - return ( {}}> @@ -277,6 +280,7 @@ const SourceMonitorContent = memo(function SourceMonitorContent({ interactive={interactive} seekFrame={seekFrame} onClose={onClose} + hotkeys={hotkeys} /> @@ -300,6 +304,7 @@ interface SourceMonitorInnerProps { interactive: boolean seekFrame: number | null onClose?: () => void + hotkeys: ReturnType } function SourceMonitorInner({ @@ -316,6 +321,7 @@ function SourceMonitorInner({ interactive, seekFrame, onClose, + hotkeys, }: SourceMonitorInnerProps) { const containerRef = useRef(null) const contentHostRef = useRef(null) @@ -440,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( @@ -448,21 +457,21 @@ function SourceMonitorInner({ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return const { currentSourceFrame, setInPoint, setOutPoint, clearInOutPoints } = useSourcePlayerStore.getState() - if (e.key === 'i' || e.key === 'I') { + if (doesHotkeyEventMatchBinding(e, markInHotkey)) { e.preventDefault() e.stopPropagation() setInPoint(currentSourceFrame) - } else if (e.key === 'o' || e.key === 'O') { + } else if (doesHotkeyEventMatchBinding(e, markOutHotkey)) { e.preventDefault() e.stopPropagation() setOutPoint(getExclusiveSourceOutPoint(currentSourceFrame, durationInFrames)) - } else if (e.altKey && (e.key === 'x' || e.key === 'X')) { + } else if (doesHotkeyEventMatchBinding(e, clearInOutHotkey)) { e.preventDefault() e.stopPropagation() clearInOutPoints() } }, - [durationInFrames, interactive], + [clearInOutHotkey, durationInFrames, interactive, markInHotkey, markOutHotkey], ) const handleMouseEnter = useCallback(() => { @@ -544,6 +553,7 @@ function SourceMonitorInner({ hasAudio={hasAudio} interactive={interactive} seekFrame={seekFrame} + hotkeys={hotkeys} /> ) @@ -558,6 +568,7 @@ function SourcePlaybackControls({ hasAudio, interactive, seekFrame, + hotkeys, }: { durationInFrames: number fps: number @@ -565,6 +576,7 @@ function SourcePlaybackControls({ hasAudio: boolean interactive: boolean seekFrame: number | null + hotkeys: ReturnType }) { const clock = useClock() const player = usePlayer(durationInFrames) @@ -587,6 +599,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 +1302,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 +1319,14 @@ 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 +1338,14 @@ 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 +1388,14 @@ 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 +1407,14 @@ 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 +1425,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 +1434,9 @@ function SourcePlaybackControls({ )} - {playing ? 'Pause' : 'Play'} (Space) + + {playing ? 'Pause' : 'Play'} ({shortcutLabel(hotkeys.PLAY_PAUSE)}) + @@ -1425,12 +1448,14 @@ 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 +1467,14 @@ 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 +1572,14 @@ 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 +1591,14 @@ 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/settings/components/hotkey-editor-reset-dialog.test.tsx b/src/features/settings/components/hotkey-editor-reset-dialog.test.tsx index 0099dff4f..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( @@ -120,37 +129,50 @@ 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', }) }) + 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/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/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.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 1afa70f9a..a3816f625 100644 --- a/src/features/settings/stores/settings-store.ts +++ b/src/features/settings/stores/settings-store.ts @@ -12,7 +12,7 @@ import { DEFAULT_EDITOR_DENSITY_PRESET, normalizeEditorDensityPreset } from '@/c import { HOTKEYS, normalizeHotkeyBinding, - sanitizeHotkeyOverrides, + resolveHotkeyConfiguration, type HotkeyKey, type HotkeyOverrideMap, } from '@/config/hotkeys' @@ -219,44 +219,44 @@ 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, + }) + if (resolution.warnings.length > 0) { return state } + const nextOverrides = resolution.overrides - return { - hotkeyOverrides: { - ...state.hotkeyOverrides, - [key]: normalizedBinding, - }, + if (areHotkeyOverridesEqual(state.hotkeyOverrides, nextOverrides)) { + return state } + + 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) => set((state) => { - const normalizedOverrides = sanitizeHotkeyOverrides(overrides) + const normalizedOverrides = resolveHotkeyConfiguration(overrides).overrides if (areHotkeyOverridesEqual(state.hotkeyOverrides, normalizedOverrides)) { return state @@ -267,13 +267,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: () => @@ -318,7 +317,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( diff --git a/src/features/timeline/components/keyframe-graph-panel.tsx b/src/features/timeline/components/keyframe-graph-panel.tsx index 6b2ecb6d7..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 { useHotkeys } from 'react-hotkeys-hook' +import { 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 { useResolvedHotkeys } 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 = useResolvedHotkeys() // Ref to measure container width const containerRef = useRef(null) const panelRef = useRef(null) @@ -2748,8 +2746,8 @@ 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( - hotkeys.KEYFRAME_EDITOR_GRAPH, + useCommandHotkey( + 'KEYFRAME_EDITOR_GRAPH', (event) => { event.preventDefault() setEditorMode('graph') @@ -2761,8 +2759,8 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( - hotkeys.KEYFRAME_EDITOR_DOPESHEET, + useCommandHotkey( + 'KEYFRAME_EDITOR_DOPESHEET', (event) => { event.preventDefault() setEditorMode('dopesheet') @@ -2774,8 +2772,8 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor], ) - useHotkeys( - hotkeys.KEYFRAME_EDITOR_SPLIT, + useCommandHotkey( + 'KEYFRAME_EDITOR_SPLIT', (event) => { event.preventDefault() setEditorMode('split') @@ -2787,8 +2785,8 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [isFocusWithinEditor, isOpen, isPointerWithinEditor, splitView], ) - useHotkeys( - hotkeys.COPY, + useCommandHotkey( + 'COPY', (event) => { event.preventDefault() handleCopyKeyframes() @@ -2800,8 +2798,8 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCopyKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( - hotkeys.CUT, + useCommandHotkey( + 'CUT', (event) => { event.preventDefault() handleCutKeyframes() @@ -2813,8 +2811,8 @@ export const KeyframeGraphPanel = memo(function KeyframeGraphPanel({ [handleCutKeyframes, isOpen, selectedEditorKeyframes.length], ) - useHotkeys( - hotkeys.PASTE, + useCommandHotkey( + 'PASTE', (event) => { event.preventDefault() handlePasteKeyframes() @@ -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/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..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 @@ -47,11 +47,22 @@ vi.mock('@/features/timeline/deps/analysis', () => ({ })) vi.mock('@/features/timeline/deps/settings', () => ({ - useResolvedHotkeys: () => ({}), + useResolvedHotkeys: () => ({ + JOIN_ITEMS: 'shift+j', + FREEZE_FRAME: 'shift+f', + DELETE_SELECTED: 'delete', + RIPPLE_DELETE: 'mod+backspace', + }), })) vi.mock('@/config/hotkeys', () => ({ - formatHotkeyBinding: () => '', + formatHotkeyBinding: (binding: string) => + ({ + 'mod+backspace': 'Ctrl + Backspace', + 'shift+j': 'Shift + J', + 'shift+f': 'Shift + F', + delete: 'Delete', + })[binding] ?? '', })) function renderContextMenu(overrides: Partial> = {}) { @@ -121,6 +132,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..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)} @@ -705,6 +707,7 @@ function CompositionActions({ function DestructiveActions({ t, + hotkeys, isSelected, canRippleDelete = true, canDelete = true, @@ -720,7 +723,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('timeline.contextMenu.rippleDelete')} - Ctrl+Del + {formatHotkeyBinding(hotkeys.RIPPLE_DELETE)} )} {canDelete && ( @@ -730,7 +733,7 @@ function DestructiveActions({ className="text-destructive focus:text-destructive" > {t('common.delete')} - Del + {formatHotkeyBinding(hotkeys.DELETE_SELECTED)} )} 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..7a3e15c79 --- /dev/null +++ b/src/features/timeline/hooks/shortcuts/runtime-conflicts.test.tsx @@ -0,0 +1,283 @@ +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, + 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' + +const runtimeHotkeysOverride = vi.hoisted(() => ({ + current: null as HotkeyBindingMap | null, +})) + +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(), + } +}) + +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) ?? '', + } +}) + +function RuntimeConflictHarness({ onJoin }: { onJoin: () => void }) { + const hotkeys = useResolvedHotkeys() + const joinBinding = getRuntimeHotkeyBinding(hotkeys, 'JOIN_ITEMS') + usePlaybackShortcuts({}) + useInOutShortcuts() + useHotkeys(joinBinding ?? [], onJoin, HOTKEY_OPTIONS, [onJoin, joinBinding]) + 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 + useSettingsStore.getState().resetHotkeys() + usePlaybackStore.setState({ + currentFrame: 48, + previewFrame: 120, + previewItemId: null, + 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', () => { + 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) + }) + + 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() + }) + + 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('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(), + 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..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 { useHotkeys } from 'react-hotkeys-hook' +import { 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 { useResolvedHotkeys } from '@/features/timeline/deps/settings' import { isCompositionWrapperItem, wouldCreateCompositionCycle, @@ -64,7 +63,6 @@ function revealPastedItems(itemIds: readonly string[]): void { } export function useClipboardShortcuts() { - const hotkeys = useResolvedHotkeys() const selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const selectedTransitionId = useSelectionStore((s) => s.selectedTransitionId) const selectedKeyframes = useKeyframeSelectionStore((s) => s.selectedKeyframes) @@ -87,8 +85,8 @@ export function useClipboardShortcuts() { } // Clipboard: Ctrl+C - Copy selected transition properties or timeline items - useHotkeys( - hotkeys.COPY, + useCommandHotkey( + 'COPY', (event) => { // Transcript editor copies the selected words instead of the clip. if (handleTranscriptClipboardCopy(false)) { @@ -134,8 +132,8 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+X - Cut selected items immediately - useHotkeys( - hotkeys.CUT, + useCommandHotkey( + 'CUT', (event) => { // Transcript editor cuts the selected words instead of the clip. if (handleTranscriptClipboardCopy(true)) { @@ -161,8 +159,8 @@ export function useClipboardShortcuts() { ) // Clipboard: Ctrl+V - Paste transition properties or timeline items - useHotkeys( - hotkeys.PASTE, + useCommandHotkey( + '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 fa4d0ddfa..9ef20b096 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 { 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 { useResolvedHotkeys } from '@/features/timeline/deps/settings' import { useKeyframeSelectionStore } from '../../stores/keyframe-selection-store' export function useDeleteShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() 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('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('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 276c038c8..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 { useHotkeys } from 'react-hotkeys-hook' +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' @@ -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 { useResolvedHotkeys } 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 selectedItemIds = useSelectionStore((s) => s.selectedItemIds) const editKeyframePanelOpen = useSelectionStore((s) => s.editKeyframePanelOpen) const clearSelection = useSelectionStore((s) => s.clearSelection) @@ -79,8 +77,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Delete - Ripple delete selected items (delete + close gap) - useHotkeys( - hotkeys.RIPPLE_DELETE, + useCommandHotkey( + 'RIPPLE_DELETE', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -101,8 +99,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Ctrl+Backspace - Ripple delete selected items (alternative) - useHotkeys( - hotkeys.RIPPLE_DELETE_ALT, + useCommandHotkey( + 'RIPPLE_DELETE_ALT', (event) => { if (deleteOwnedByPanel) { event.preventDefault() @@ -123,8 +121,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+Arrow keys - nudge selected visual items by 1px - useHotkeys( - hotkeys.NUDGE_LEFT, + useCommandHotkey( + 'NUDGE_LEFT', (event) => { event.preventDefault() nudgeSelectedVisualItems(-1, 0) @@ -133,8 +131,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_RIGHT, + useCommandHotkey( + 'NUDGE_RIGHT', (event) => { event.preventDefault() nudgeSelectedVisualItems(1, 0) @@ -143,8 +141,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_UP, + useCommandHotkey( + 'NUDGE_UP', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -1) @@ -153,8 +151,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_DOWN, + useCommandHotkey( + 'NUDGE_DOWN', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 1) @@ -164,8 +162,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Cmd/Ctrl+Shift+Arrow keys - nudge selected visual items by 10px - useHotkeys( - hotkeys.NUDGE_LEFT_LARGE, + useCommandHotkey( + 'NUDGE_LEFT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(-10, 0) @@ -174,8 +172,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_RIGHT_LARGE, + useCommandHotkey( + 'NUDGE_RIGHT_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(10, 0) @@ -184,8 +182,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_UP_LARGE, + useCommandHotkey( + 'NUDGE_UP_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, -10) @@ -194,8 +192,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [nudgeSelectedVisualItems], ) - useHotkeys( - hotkeys.NUDGE_DOWN_LARGE, + useCommandHotkey( + 'NUDGE_DOWN_LARGE', (event) => { event.preventDefault() nudgeSelectedVisualItems(0, 10) @@ -205,8 +203,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Editing: Shift+J - Join selected clips - useHotkeys( - hotkeys.JOIN_ITEMS, + useCommandHotkey( + 'JOIN_ITEMS', (event) => { if (selectedItemIds.length < 2) return @@ -225,8 +223,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items, joinItems], ) - useHotkeys( - hotkeys.LINK_AUDIO_VIDEO, + useCommandHotkey( + 'LINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length < 2) return if (!canLinkSelection(items, selectedItemIds)) return @@ -238,8 +236,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( - hotkeys.UNLINK_AUDIO_VIDEO, + useCommandHotkey( + 'UNLINK_AUDIO_VIDEO', (event) => { if (selectedItemIds.length === 0) return if (!selectedItemIds.some((id) => hasLinkedItems(items, id))) return @@ -251,8 +249,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { [selectedItemIds, items], ) - useHotkeys( - hotkeys.TOGGLE_LINKED_SELECTION, + useCommandHotkey( + 'TOGGLE_LINKED_SELECTION', (event) => { event.preventDefault() toggleLinkedSelectionEnabled() @@ -269,16 +267,16 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { }, []) // Editing: Alt+C - Split all items at gray playhead (or main playhead) - useHotkeys( - hotkeys.SPLIT_AT_PLAYHEAD_ALT, + useCommandHotkey( + 'SPLIT_AT_PLAYHEAD_ALT', splitAtPlayhead, { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } }, [splitAtPlayhead], ) // Editing: Shift+F - Insert freeze frame at playhead - useHotkeys( - hotkeys.FREEZE_FRAME, + useCommandHotkey( + 'FREEZE_FRAME', (event) => { if (selectedItemIds.length !== 1) return const currentFrame = usePlaybackStore.getState().currentFrame @@ -300,8 +298,8 @@ export function useEditingShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Keyframes: Shift+A - Clear all keyframes for selected items (with confirmation) - useHotkeys( - hotkeys.CLEAR_KEYFRAMES, + useCommandHotkey( + '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 7c1c75e0e..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,34 +2,14 @@ * Timeline in/out shortcuts: I, O, Shift+I/O, Alt+X. */ -import { useHotkeys } from 'react-hotkeys-hook' +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' -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) - - useHotkeys( - hotkeys.MARK_IN, + useCommandHotkey( + 'MARK_IN', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -39,19 +19,20 @@ 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( - hotkeys.MARK_OUT, + useCommandHotkey( + 'MARK_OUT', (event) => { event.preventDefault() const { currentFrame } = usePlaybackStore.getState() @@ -61,19 +42,20 @@ 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( - hotkeys.CLEAR_IN_OUT, + useCommandHotkey( + '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 cd6881792..e5073feb0 100644 --- a/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-marker-shortcuts.ts @@ -2,22 +2,20 @@ * Marker shortcuts: M (add), Shift+M (remove), [ ] (navigate). */ -import { useHotkeys } from 'react-hotkeys-hook' +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' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import { addMarker, removeMarker } from '../../stores/actions/marker-actions' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' export function useMarkerShortcuts() { - const hotkeys = useResolvedHotkeys() const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame) const clearSelection = useSelectionStore((s) => s.clearSelection) // Markers: M - Add marker at playhead - useHotkeys( - hotkeys.ADD_MARKER, + useCommandHotkey( + 'ADD_MARKER', (event) => { event.preventDefault() const { previewFrame, currentFrame } = usePlaybackStore.getState() @@ -28,8 +26,8 @@ export function useMarkerShortcuts() { ) // Markers: Shift+M - Remove selected marker - useHotkeys( - hotkeys.REMOVE_MARKER, + useCommandHotkey( + 'REMOVE_MARKER', (event) => { event.preventDefault() const id = useSelectionStore.getState().selectedMarkerId @@ -43,8 +41,8 @@ export function useMarkerShortcuts() { ) // Markers: [ - Jump to previous marker - useHotkeys( - hotkeys.PREVIOUS_MARKER, + useCommandHotkey( + 'PREVIOUS_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers @@ -67,8 +65,8 @@ export function useMarkerShortcuts() { ) // Markers: ] - Jump to next marker - useHotkeys( - hotkeys.NEXT_MARKER, + useCommandHotkey( + 'NEXT_MARKER', (event) => { event.preventDefault() const currentMarkers = useMarkersStore.getState().markers 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..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 { useHotkeys } from 'react-hotkeys-hook' +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' @@ -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 { useResolvedHotkeys } 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 = useResolvedHotkeys() const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause) const shuttleForward = usePlaybackStore((s) => s.shuttleForward) const shuttleReverse = usePlaybackStore((s) => s.shuttleReverse) @@ -54,8 +52,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Playback: Space - Play/Pause - useHotkeys( - hotkeys.PLAY_PAUSE, + useCommandHotkey( + 'PLAY_PAUSE', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -74,10 +72,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', + useCommandHotkey( + 'SHUTTLE_FORWARD', (event) => { if (event.repeat) return event.preventDefault() @@ -96,10 +94,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', + useCommandHotkey( + 'SHUTTLE_REVERSE', (event) => { if (event.repeat) return event.preventDefault() @@ -118,33 +116,32 @@ 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. - useHotkeys( - 'k', + // Pause always owns its binding, including while already paused, so transport + // routing cannot fall through to another command. + useCommandHotkey( + '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], ) // Navigation: Arrow Left - Previous frame - useHotkeys( - hotkeys.PREVIOUS_FRAME, + useCommandHotkey( + 'PREVIOUS_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -160,8 +157,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Arrow Right - Next frame - useHotkeys( - hotkeys.NEXT_FRAME, + useCommandHotkey( + 'NEXT_FRAME', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -177,8 +174,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Home - Go to start - useHotkeys( - hotkeys.GO_TO_START, + useCommandHotkey( + 'GO_TO_START', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -193,8 +190,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: End - Go to end of timeline (last frame of last item) - useHotkeys( - hotkeys.GO_TO_END, + useCommandHotkey( + 'GO_TO_END', (event) => { event.preventDefault() const { hoveredPanel, playerMethods } = useSourcePlayerStore.getState() @@ -214,8 +211,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Down - Jump to next snap point (clip edge or marker) - useHotkeys( - hotkeys.NEXT_SNAP_POINT, + useCommandHotkey( + 'NEXT_SNAP_POINT', (event) => { event.preventDefault() const currentFrame = usePlaybackStore.getState().currentFrame @@ -229,8 +226,8 @@ export function usePlaybackShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Navigation: Up - Jump to previous snap point (clip edge or marker) - useHotkeys( - hotkeys.PREVIOUS_SNAP_POINT, + useCommandHotkey( + '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 0cabacc2c..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,18 +9,15 @@ * source monitor is hovered/focused. */ -import { useHotkeys } from 'react-hotkeys-hook' +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' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' export function useSourceMonitorShortcuts() { - const hotkeys = useResolvedHotkeys() - // Insert Edit: , (comma) — works globally when source monitor is open - useHotkeys( - hotkeys.INSERT_EDIT, + useCommandHotkey( + 'INSERT_EDIT', (event) => { event.preventDefault() const sourceMediaId = useEditorStore.getState().sourcePreviewMediaId @@ -32,8 +29,8 @@ export function useSourceMonitorShortcuts() { ) // Overwrite Edit: . (period) — works globally when source monitor is open - useHotkeys( - hotkeys.OVERWRITE_EDIT, + useCommandHotkey( + '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 9dc5a4c1c..4e8ac7d94 100644 --- a/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts +++ b/src/features/timeline/hooks/shortcuts/use-tool-shortcuts.ts @@ -1,24 +1,22 @@ /** - * 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' +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' import { HOTKEY_OPTIONS } from '@/config/hotkeys' import type { TimelineShortcutCallbacks } from '../use-timeline-shortcuts' -import { useResolvedHotkeys } from '@/features/timeline/deps/settings' import { SLIP_SLIDE_TOOLS_ENABLED } from '../../constants' export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { - const hotkeys = useResolvedHotkeys() const activeTool = useSelectionStore((s) => s.activeTool) const setActiveTool = useSelectionStore((s) => s.setActiveTool) // Tool: V - Selection Tool - useHotkeys( - hotkeys.SELECTION_TOOL, + useCommandHotkey( + 'SELECTION_TOOL', (event) => { event.preventDefault() setActiveTool('select') @@ -28,8 +26,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: T - Toggle Trim Edit Tool - useHotkeys( - hotkeys.TRIM_EDIT_TOOL, + useCommandHotkey( + 'TRIM_EDIT_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'trim-edit' ? 'select' : 'trim-edit') @@ -39,8 +37,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: C - Toggle Razor/Cut Mode - useHotkeys( - hotkeys.RAZOR_TOOL, + useCommandHotkey( + 'RAZOR_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'razor' ? 'select' : 'razor') @@ -50,8 +48,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Shift+C - Split hovered item at gray playhead (or main playhead) - useHotkeys( - hotkeys.SPLIT_AT_CURSOR, + useCommandHotkey( + 'SPLIT_AT_PLAYHEAD', (event) => { event.preventDefault() const { previewFrame, previewItemId, currentFrame } = usePlaybackStore.getState() @@ -74,8 +72,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: R - Toggle Rate Stretch Tool - useHotkeys( - hotkeys.RATE_STRETCH_TOOL, + useCommandHotkey( + 'RATE_STRETCH_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'rate-stretch' ? 'select' : 'rate-stretch') @@ -85,8 +83,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: Y - Toggle Slip Tool - useHotkeys( - hotkeys.SLIP_TOOL, + useCommandHotkey( + 'SLIP_TOOL', (event) => { event.preventDefault() setActiveTool(activeTool === 'slip' ? 'select' : 'slip') @@ -96,8 +94,8 @@ export function useToolShortcuts(callbacks: TimelineShortcutCallbacks) { ) // Tool: U - Toggle Slide Tool - useHotkeys( - hotkeys.SLIDE_TOOL, + useCommandHotkey( + '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 cffd9cbc7..1bd43ab02 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 { 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 { useResolvedHotkeys, useSettingsStore } from '@/features/timeline/deps/settings' +import { useSettingsStore } from '@/features/timeline/deps/settings' export interface UIShortcutOptions { /** @@ -23,14 +23,13 @@ export function useUIShortcuts( options: UIShortcutOptions = {}, ) { const { enableHistory = true } = options - const hotkeys = useResolvedHotkeys() const toggleSnap = useTimelineStore((s) => s.toggleSnap) const zoomIn = useZoomStore((s) => s.zoomIn) const zoomOut = useZoomStore((s) => s.zoomOut) // History: Cmd/Ctrl+Z - Undo - useHotkeys( - hotkeys.UNDO, + useCommandHotkey( + 'UNDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().undo() @@ -47,8 +46,8 @@ export function useUIShortcuts( ) // History: Cmd/Ctrl+Shift+Z - Redo - useHotkeys( - hotkeys.REDO, + useCommandHotkey( + 'REDO', (event) => { event.preventDefault() useTimelineStore.temporal.getState().redo() @@ -65,8 +64,8 @@ export function useUIShortcuts( ) // UI: S - Toggle Snap - useHotkeys( - hotkeys.TOGGLE_SNAP, + useCommandHotkey( + 'TOGGLE_SNAP', (event) => { event.preventDefault() toggleSnap() @@ -76,8 +75,8 @@ export function useUIShortcuts( ) // UI: Shift+S - Toggle Canvas (gizmo) Snap — independent from timeline snap. - useHotkeys( - hotkeys.TOGGLE_CANVAS_SNAP, + useCommandHotkey( + 'TOGGLE_CANVAS_SNAP', (event) => { event.preventDefault() const s = useSettingsStore.getState() @@ -90,8 +89,8 @@ export function useUIShortcuts( const zoomHotkeyOptions = { ...HOTKEY_OPTIONS, eventListenerOptions: { capture: true } } // Zoom: Cmd/Ctrl+Equals - Zoom in - useHotkeys( - hotkeys.ZOOM_IN, + useCommandHotkey( + 'ZOOM_IN', (event) => { event.preventDefault() zoomIn() @@ -101,8 +100,8 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+Minus - Zoom out - useHotkeys( - hotkeys.ZOOM_OUT, + useCommandHotkey( + 'ZOOM_OUT', (event) => { event.preventDefault() zoomOut() @@ -112,8 +111,8 @@ export function useUIShortcuts( ) // Zoom: Backslash - Zoom to Fit - useHotkeys( - hotkeys.ZOOM_TO_FIT, + useCommandHotkey( + 'ZOOM_TO_FIT', (event) => { event.preventDefault() if (callbacks.onZoomToFit) { @@ -144,8 +143,8 @@ export function useUIShortcuts( ) // Zoom: Shift+Backslash - Zoom to 100% centered on cursor (or playhead if cursor not on timeline) - useHotkeys( - hotkeys.ZOOM_TO_100, + useCommandHotkey( + 'ZOOM_TO_100', (event) => { event.preventDefault() const { currentFrame, previewFrame } = usePlaybackStore.getState() @@ -161,8 +160,8 @@ export function useUIShortcuts( ) // Zoom: Cmd/Ctrl+0 - Reset timeline zoom to 100% - useHotkeys( - hotkeys.ZOOM_TO_100_ALT, + useCommandHotkey( + 'ZOOM_TO_100_ALT', (event) => { event.preventDefault() const { currentFrame, previewFrame } = 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/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 new file mode 100644 index 000000000..c57aeabff --- /dev/null +++ b/src/hooks/use-hotkey-registration.ts @@ -0,0 +1,63 @@ +import type { DependencyList } from 'react' +import { useHotkeys, type HotkeyCallback, type Options } from 'react-hotkeys-hook' +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' + +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] + }) +} 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": "重做",