| 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">${tagName}>
`,
+ '#control',
+ 'j',
+ )
+
+ expect(result).toEqual({ captureSawEvent: true, defaultPrevented: false })
+ })
+
+ it.each([
+ ['native button', ''],
+ ['native link', 'Project'],
+ ['summary', 'Details
'],
+ ['button role', 'Run
'],
+ ['menuitem role', 'Open
'],
+ ])('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(
+ '',
+ '#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": "重做",