341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { resolveAction, type ActionId } from '../../lib/keyboard-shortcuts'
|
|
import type { EditorState } from './editor-state'
|
|
import type { SourceKeyboardAction } from './VideoEditorSourceMonitor'
|
|
import {
|
|
selectActiveFocusArea,
|
|
selectActiveTimelineInPoint,
|
|
selectActiveTimelineOutPoint,
|
|
selectClips,
|
|
selectKeyboardCommandContext,
|
|
selectSelectedGap,
|
|
selectSelectedSubtitleId,
|
|
selectShuttleSpeed,
|
|
selectTracks,
|
|
} from './editor-selectors'
|
|
import { useEditorActions } from './editor-store'
|
|
|
|
// Frame duration at 24fps
|
|
const FRAME_DURATION = 1 / 24
|
|
|
|
const FORWARD_SPEEDS = [1, 2, 4, 8]
|
|
const REVERSE_SPEEDS = [-1, -2, -4, -8]
|
|
|
|
interface KeyboardRefs {
|
|
kbLayoutRef: React.MutableRefObject<any>
|
|
isKbEditorOpenRef: React.MutableRefObject<boolean>
|
|
getState: () => EditorState
|
|
playbackTimeRef: React.MutableRefObject<number>
|
|
sourceDispatchRef: React.MutableRefObject<(action: SourceKeyboardAction) => void>
|
|
sourcePauseRef: React.MutableRefObject<() => void>
|
|
centerOnPlayheadRef: React.MutableRefObject<boolean>
|
|
getMinZoomRef: React.MutableRefObject<() => number>
|
|
gapGenerateModeRef: React.MutableRefObject<'text-to-video' | 'image-to-video' | 'text-to-image' | null>
|
|
clearSelectedGapRef: React.MutableRefObject<() => void>
|
|
closeSelectedGapRef: React.MutableRefObject<() => void>
|
|
fitToViewRef: React.MutableRefObject<() => void>
|
|
toggleFullscreenRef: React.MutableRefObject<() => void>
|
|
insertEditRef: React.MutableRefObject<() => void>
|
|
overwriteEditRef: React.MutableRefObject<() => void>
|
|
matchFrameRef: React.MutableRefObject<() => void>
|
|
}
|
|
|
|
interface KeyboardContext {
|
|
deleteAssetActionRef: React.MutableRefObject<() => void>
|
|
}
|
|
|
|
export interface UseEditorKeyboardParams {
|
|
refs: KeyboardRefs
|
|
context: KeyboardContext
|
|
}
|
|
|
|
export function useEditorKeyboard(params: UseEditorKeyboardParams) {
|
|
const { refs, context } = params
|
|
const actions = useEditorActions()
|
|
const kHeldRef = useRef(false)
|
|
const contextRef = useRef(context)
|
|
const actionsRef = useRef(actions)
|
|
contextRef.current = context
|
|
actionsRef.current = actions
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
if (refs.isKbEditorOpenRef.current) return
|
|
|
|
const context = contextRef.current
|
|
const state = refs.getState()
|
|
const commandContext = selectKeyboardCommandContext(state)
|
|
const sel = commandContext.selectedClipIds
|
|
const td = commandContext.totalDuration
|
|
const focusArea = selectActiveFocusArea(state)
|
|
|
|
const action: ActionId | null = resolveAction(refs.kbLayoutRef.current, e)
|
|
if (!action) return
|
|
|
|
e.preventDefault()
|
|
const editorActions = actionsRef.current
|
|
|
|
switch (action) {
|
|
// Tools
|
|
case 'tool.select': editorActions.setActiveTool('select'); break
|
|
case 'tool.blade': editorActions.setActiveTool('blade'); break
|
|
case 'tool.ripple': editorActions.setActiveTool('ripple'); editorActions.setLastTrimTool('ripple'); break
|
|
case 'tool.roll': editorActions.setActiveTool('roll'); editorActions.setLastTrimTool('roll'); break
|
|
case 'tool.slide': editorActions.setActiveTool('slide'); editorActions.setLastTrimTool('slide'); break
|
|
case 'tool.slip': editorActions.setActiveTool('slip'); editorActions.setLastTrimTool('slip'); break
|
|
case 'tool.trackForward': editorActions.setActiveTool('trackForward'); break
|
|
|
|
// Transport — panel-aware
|
|
case 'transport.playPause':
|
|
if (focusArea === 'source') {
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
refs.sourceDispatchRef.current('transport.playPause')
|
|
} else {
|
|
refs.sourcePauseRef.current()
|
|
const wasPlaying = state.session.transport.isPlaying
|
|
const hadShuttle = state.session.transport.shuttleSpeed !== 0
|
|
editorActions.stopShuttle()
|
|
if (wasPlaying || hadShuttle) editorActions.pause()
|
|
else editorActions.play()
|
|
}
|
|
break
|
|
|
|
case 'transport.shuttleReverse':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.shuttleReverse')
|
|
} else {
|
|
refs.sourcePauseRef.current()
|
|
if (kHeldRef.current) {
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.stepCurrentTime(-FRAME_DURATION)
|
|
} else {
|
|
const currentSpeed = selectShuttleSpeed(state)
|
|
const nextSpeed = currentSpeed > 0
|
|
? -1
|
|
: REVERSE_SPEEDS[Math.max(0, Math.min(REVERSE_SPEEDS.length - 1, REVERSE_SPEEDS.indexOf(currentSpeed) + 1 || 0))]
|
|
editorActions.setShuttleSpeed(nextSpeed)
|
|
editorActions.play()
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'transport.shuttleStop':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.shuttleStop')
|
|
} else {
|
|
kHeldRef.current = true
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
}
|
|
break
|
|
|
|
case 'transport.shuttleForward':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current(kHeldRef.current ? 'transport.stepForward' : 'transport.shuttleForward')
|
|
} else {
|
|
refs.sourcePauseRef.current()
|
|
if (kHeldRef.current) {
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.stepCurrentTime(FRAME_DURATION)
|
|
} else {
|
|
const currentSpeed = selectShuttleSpeed(state)
|
|
const nextSpeed = currentSpeed < 0
|
|
? 1
|
|
: FORWARD_SPEEDS[Math.max(0, Math.min(FORWARD_SPEEDS.length - 1, FORWARD_SPEEDS.indexOf(currentSpeed) + 1 || 0))]
|
|
editorActions.setShuttleSpeed(nextSpeed)
|
|
editorActions.play()
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'transport.stepBackward':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.stepBackward')
|
|
} else {
|
|
editorActions.stepCurrentTime(-FRAME_DURATION)
|
|
}
|
|
break
|
|
|
|
case 'transport.stepForward':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.stepForward')
|
|
} else {
|
|
const nextTime = Math.min(commandContext.totalDuration, commandContext.currentTime + FRAME_DURATION)
|
|
editorActions.setCurrentTime(nextTime)
|
|
}
|
|
break
|
|
|
|
case 'transport.jumpBackward':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.jumpBackward')
|
|
} else {
|
|
editorActions.stepCurrentTime(-1)
|
|
}
|
|
break
|
|
|
|
case 'transport.jumpForward':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('transport.jumpForward')
|
|
} else {
|
|
const nextTime = Math.min(commandContext.totalDuration, commandContext.currentTime + 1)
|
|
editorActions.setCurrentTime(nextTime)
|
|
}
|
|
break
|
|
|
|
case 'transport.goToStart':
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.setCurrentTime(0)
|
|
break
|
|
case 'transport.goToEnd':
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.setCurrentTime(td)
|
|
break
|
|
case 'transport.goToIn': {
|
|
const { inPoint, clips } = commandContext
|
|
const target = inPoint ?? (clips.length > 0 ? Math.min(...clips.map(c => c.startTime)) : 0)
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.setCurrentTime(target)
|
|
break
|
|
}
|
|
case 'transport.goToOut': {
|
|
const { outPoint, clips: clipsOut, totalDuration: tdOut } = commandContext
|
|
const target = outPoint ?? (clipsOut.length > 0 ? Math.max(...clipsOut.map(c => c.startTime + c.duration)) : tdOut)
|
|
editorActions.pause()
|
|
editorActions.stopShuttle()
|
|
editorActions.setCurrentTime(target)
|
|
break
|
|
}
|
|
|
|
// Editing
|
|
case 'edit.undo': editorActions.undo(); break
|
|
case 'edit.redo': editorActions.redo(); break
|
|
case 'edit.cut': editorActions.cutSelection(); break
|
|
case 'edit.copy': editorActions.copySelection(); break
|
|
case 'edit.paste': editorActions.pasteSelection(); break
|
|
case 'edit.selectAll':
|
|
editorActions.selectAllClips()
|
|
break
|
|
case 'edit.deselect':
|
|
if (refs.gapGenerateModeRef.current) {
|
|
refs.clearSelectedGapRef.current()
|
|
} else {
|
|
editorActions.clearClipSelection()
|
|
}
|
|
break
|
|
case 'edit.delete':
|
|
if (sel.size > 0) {
|
|
const deleteIds = new Set<string>()
|
|
for (const id of sel) {
|
|
const clip = selectClips(state).find(cl => cl.id === id)
|
|
if (clip && selectTracks(state)[clip.trackIndex]?.locked) continue
|
|
deleteIds.add(id)
|
|
if (clip?.linkedClipIds) {
|
|
const allLinkedSelected = clip.linkedClipIds.every(lid => sel.has(lid))
|
|
if (allLinkedSelected) clip.linkedClipIds.forEach(lid => deleteIds.add(lid))
|
|
}
|
|
}
|
|
editorActions.deleteClips([...deleteIds])
|
|
} else if (selectSelectedGap(state)) {
|
|
refs.closeSelectedGapRef.current()
|
|
} else if (selectSelectedSubtitleId(state)) {
|
|
editorActions.deleteSubtitle(selectSelectedSubtitleId(state)!)
|
|
} else {
|
|
context.deleteAssetActionRef.current()
|
|
}
|
|
break
|
|
case 'edit.insertEdit': refs.insertEditRef.current(); break
|
|
case 'edit.overwriteEdit': refs.overwriteEditRef.current(); break
|
|
case 'edit.matchFrame': refs.matchFrameRef.current(); break
|
|
|
|
// Marking — panel-aware
|
|
case 'mark.setIn':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('mark.setIn')
|
|
} else {
|
|
const ct = commandContext.currentTime
|
|
const currentIn = selectActiveTimelineInPoint(state)
|
|
editorActions.setTimelineInPoint(currentIn !== null && Math.abs(currentIn - ct) < 0.01 ? null : ct)
|
|
}
|
|
break
|
|
case 'mark.setOut':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('mark.setOut')
|
|
} else {
|
|
const ct = commandContext.currentTime
|
|
const currentOut = selectActiveTimelineOutPoint(state)
|
|
editorActions.setTimelineOutPoint(currentOut !== null && Math.abs(currentOut - ct) < 0.01 ? null : ct)
|
|
}
|
|
break
|
|
case 'mark.clearIn':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('mark.clearIn')
|
|
} else {
|
|
editorActions.clearTimelineInPoint()
|
|
}
|
|
break
|
|
case 'mark.clearOut':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('mark.clearOut')
|
|
} else {
|
|
editorActions.clearTimelineOutPoint()
|
|
}
|
|
break
|
|
case 'mark.clearInOut':
|
|
if (focusArea === 'source') {
|
|
refs.sourceDispatchRef.current('mark.clearInOut')
|
|
} else {
|
|
editorActions.clearTimelineMarks()
|
|
}
|
|
break
|
|
|
|
// Timeline
|
|
case 'timeline.zoomIn':
|
|
refs.centerOnPlayheadRef.current = true
|
|
editorActions.setZoom(Math.min(4, +(state.session.tools.zoom + 0.25).toFixed(2)))
|
|
break
|
|
case 'timeline.zoomOut':
|
|
refs.centerOnPlayheadRef.current = true
|
|
editorActions.setZoom(Math.max(refs.getMinZoomRef.current(), +(state.session.tools.zoom - 0.25).toFixed(2)))
|
|
break
|
|
case 'timeline.fitToView':
|
|
refs.fitToViewRef.current()
|
|
break
|
|
case 'timeline.toggleSnap':
|
|
editorActions.toggleSnap()
|
|
break
|
|
case 'nav.prevEdit':
|
|
editorActions.pause()
|
|
editorActions.goToPrevEdit(state.session.transport.isPlaying ? refs.playbackTimeRef.current : state.session.transport.currentTime)
|
|
break
|
|
case 'nav.nextEdit':
|
|
editorActions.pause()
|
|
editorActions.goToNextEdit(state.session.transport.isPlaying ? refs.playbackTimeRef.current : state.session.transport.currentTime)
|
|
break
|
|
case 'view.fullscreen':
|
|
refs.toggleFullscreenRef.current()
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key.toLowerCase() === 'k') {
|
|
kHeldRef.current = false
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keyup', handleKeyUp)
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
window.removeEventListener('keyup', handleKeyUp)
|
|
}
|
|
}, []) // stable - uses refs for latest state
|
|
}
|