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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/freecut-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,40 @@ shortcut editor, including J/K/L transport. UI changes call `setSettings`, and
host or agent changes can flow back through `subscribe`, so embedded shortcut
configuration never becomes a UI-only setting.

As of 0.3.13, Delete/Backspace and cut shortcuts work when the timeline clip
itself has keyboard focus. Editable fields, nested controls, ordinary buttons,
and clips inside dialogs retain shortcut protection. C cuts the hovered clip at
the pointer frame; with the pointer away, it cuts exactly one selected clip at
the playhead. No selection, multiple selections, and a playhead at either clip
endpoint produce no fallback cut.

Host-backed undo/redo in 0.3.13 is opt-in through `EditorHost.history`:

```ts
history: {
undo: () => restoreSavedHistory('undo'),
redo: () => restoreSavedHistory('redo'),
}
```

Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z invoke those callbacks. The host owns history,
request serialization, conflict handling and persistence, and publishes the
resulting authoritative snapshot through its existing `subscribe` port. The
surface never rolls back its local temporal store in host mode. Without the
history port, host-mode undo/redo stays disabled; standalone history continues
to use the local store. Input fields, nested controls and dialogs keep their
native keyboard behavior.

CodePress requires its durable-history implementation (quantfive/codepress#6428)
and the companion history-port wiring in addition to the 0.3.13 package update;
a package upgrade alone cannot enable saved undo in a host without history.

This release incorporates the source equivalents of the focused-clip and
selected-playhead shortcut hunks in CodePress's 0.3.12 vendor patch
(quantfive/codepress#7001). Once CodePress pins this published version, remove
those two shortcut hunks while preserving unrelated vendor fixes, regenerate
the patch hash, and verify the installed package through the real host.

As of 0.3.12, host-mode timeline clips use durable forward attachment chains by
default. A detached clip is an explicit ripple break and can be reattached from
its context menu. The host-mode Delete action and Delete/Backspace shortcuts submit
Expand Down Expand Up @@ -137,5 +171,5 @@ Consumers install the exact published version and keep it pinned in their
lockfile:

```bash
npm install @quantfive/freecut-editor-surface@0.3.12
npm install @quantfive/freecut-editor-surface@0.3.13
```
3 changes: 3 additions & 0 deletions packages/freecut-editor/consumer-smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type EditorTranscriptPort,
type EmbeddedEditorSnapshot,
type HostEditPredicate,
type EditorHistoryPort,
type HostNotice,
type HostTimelineEditPort,
} from '@quantfive/freecut-editor-surface'
Expand Down Expand Up @@ -146,9 +147,11 @@ describe('published FreeCut browser entry', () => {
}
const requestTranscription = vi.fn<NonNullable<EditorTranscriptPort['requestTranscription']>>()
const timelinePort: HostTimelineEditPort = { requestRippleDelete: vi.fn() }
const historyPort: EditorHistoryPort = { undo: vi.fn(), redo: vi.fn() }
expect(notice.detail?.failedPredicates).toEqual(['sourceRange'])
expect(requestTranscription).toBeTypeOf('function')
expect(timelinePort.requestRippleDelete).toBeTypeOf('function')
expect(historyPort.undo).toBeTypeOf('function')

const typedMetadataSnapshot: EmbeddedEditorSnapshot = {
...snapshot,
Expand Down
2 changes: 1 addition & 1 deletion packages/freecut-editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantfive/freecut-editor-surface",
"version": "0.3.12",
"version": "0.3.13",
"description": "The host-backed FreeCut browser editor surface.",
"license": "MIT",
"repository": {
Expand Down
6 changes: 6 additions & 0 deletions packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,11 @@ export interface EditorShortcutPort {
subscribe?(listener: (settings: HostShortcutSettings) => void): () => void
}

export interface EditorHistoryPort {
undo(): Promise<void> | void
redo(): Promise<void> | void
}

export declare function createHostShortcutSettings(
overrides?: HotkeyOverrideMap,
): HostShortcutSettings
Expand All @@ -469,6 +474,7 @@ export interface EditorHost {
submitEdit(batch: EditCommandBatch): Promise<HostEditResult> | HostEditResult
subscribe?(listener: (snapshot: EmbeddedEditorSnapshot) => void): () => void
shortcuts?: EditorShortcutPort
history?: EditorHistoryPort
transcript?: EditorTranscriptPort
navigation?: EditorHostNavigation
notify?(notice: HostNotice): void
Expand Down
1 change: 1 addition & 0 deletions packages/freecut-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type {
EditorCapability,
EditorCapabilityMap,
EditorHost,
EditorHistoryPort,
EditorHostNavigation,
EditorShortcutPort,
EmbeddedEditorAsset,
Expand Down
40 changes: 40 additions & 0 deletions src/config/hotkeys-dom-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,46 @@ describe('global shortcut DOM guards', () => {
})
})

it('allows shortcuts when the exact timeline item root owns focus', () => {
expect(
dispatchFrom(
'<div data-timeline-item data-item-id="clip-1" role="button" id="clip">Clip</div>',
'#clip',
'k',
),
).toEqual({ captureSawEvent: true, defaultPrevented: true })
})

it('keeps nested controls inside a timeline item protected', () => {
expect(
dispatchFrom(
'<div data-timeline-item data-item-id="clip-1" role="button"><button id="control">Run</button></div>',
'#control',
'k',
),
).toEqual({ captureSawEvent: true, defaultPrevented: false })
})

it('keeps a focused timeline item inside a dialog protected', () => {
expect(
dispatchFrom(
'<div role="dialog"><div data-timeline-item data-item-id="clip-1" role="button" id="clip">Clip</div></div>',
'#clip',
'k',
),
).toEqual({ captureSawEvent: true, defaultPrevented: false })
})

it('keeps a contenteditable timeline item protected', () => {
expect(
dispatchFrom(
'<div data-timeline-item data-item-id="clip-1" role="button" contenteditable="true" id="clip">Clip</div>',
'#clip',
'k',
),
).toEqual({ captureSawEvent: true, defaultPrevented: false })
})

it('preserves explicit canvas opt-in inside a native dialog', () => {
expect(
dispatchFrom(
Expand Down
16 changes: 6 additions & 10 deletions src/config/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1261,16 +1261,12 @@ export function shouldIgnoreGlobalHotkey(event: KeyboardEvent): boolean {
if (typeof Element === 'undefined' || !(target instanceof Element)) return false
if (target.closest(GLOBAL_HOTKEY_OPT_IN)) return false
if (isContentEditableTarget(target)) return true
// A Razor click leaves focus on the semantic clip root. Preserve the
// interactive surface's normal transport/activation behavior, but let
// history shortcuts reach the timeline controller so one click-cut can be
// undone without first moving focus away from the clip.
const isHistoryShortcut =
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
event.key.toLowerCase() === 'z' &&
target.closest('[data-timeline-item]') !== null
if (isHistoryShortcut) return false
if (
target.matches('[data-timeline-item][data-item-id][role="button"]') &&
!target.closest(DIALOG_SELECTOR)
) {
return false
}
if (target.closest(INTERACTIVE_CONTROL_SELECTOR)) return true
return target.closest(DIALOG_SELECTOR) !== null
}
Expand Down
4 changes: 2 additions & 2 deletions src/features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,8 @@ const TimelineShortcutsController = memo(function TimelineShortcutsController()
})

// Host mode mounts only the host-safe shortcut slice (playback, tools,
// delete, zoom/snap) — undo/redo, ripple delete, clipboard, markers, and
// nudges would mutate local stores without crossing the host bridge.
// delete, zoom/snap, and optional host-owned undo/redo) — ripple delete,
// clipboard, markers, and nudges remain outside the host bridge.
const HostTimelineShortcutsController = memo(function HostTimelineShortcutsController() {
useHostTimelineShortcuts()
return null
Expand Down
12 changes: 12 additions & 0 deletions src/features/editor/host/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@ export interface EditorShortcutPort {
subscribe?(listener: (settings: HostShortcutSettings) => void): () => void
}

/**
* Optional host-owned history boundary. The host persists and serializes the
* authoritative history; after applying an action it pushes the resulting
* snapshot through EditorHost.subscribe when the surface needs an update.
*/
export interface EditorHistoryPort {
undo(): Promise<void> | void
redo(): Promise<void> | void
}

export function createHostShortcutSettings(
overrides: HotkeyOverrideMap = {},
): HostShortcutSettings {
Expand Down Expand Up @@ -374,6 +384,8 @@ export interface EditorHost {
subscribe?(listener: (snapshot: EmbeddedEditorSnapshot) => void): () => void
/** Optional host/agent round-trip for user-configurable keyboard shortcuts. */
shortcuts?: EditorShortcutPort
/** Optional host-owned undo/redo boundary for host-mode timeline history. */
history?: EditorHistoryPort
/** Optional application-issued transcript read/preview boundary. */
transcript?: EditorTranscriptPort
navigation?: EditorHostNavigation
Expand Down
1 change: 1 addition & 0 deletions src/features/editor/host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
EditorCapability,
EditorCapabilityMap,
EditorHost,
EditorHistoryPort,
EditorHostNavigation,
EditorShortcutPort,
EmbeddedEditorAsset,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('TimelineItem keyboard accessibility', () => {
it.each([
['Enter', 'Enter'],
[' ', 'Space'],
])('activates exactly once with %s after global capture declines it', (key, code) => {
])('activates exactly once with %s while the clip root owns focus', (key, code) => {
const selectItems = vi.spyOn(useSelectionStore.getState(), 'selectItems')
const togglePlayPause = vi.spyOn(usePlaybackStore.getState(), 'togglePlayPause')
const captureListener = vi.fn()
Expand All @@ -190,7 +190,11 @@ describe('TimelineItem keyboard accessibility', () => {
expect(event.defaultPrevented).toBe(true)
expect(selectItems).toHaveBeenCalledTimes(1)
expect(selectItems).toHaveBeenLastCalledWith([ITEM.id])
expect(togglePlayPause).not.toHaveBeenCalled()
if (key === ' ') {
expect(togglePlayPause).toHaveBeenCalledTimes(1)
} else {
expect(togglePlayPause).not.toHaveBeenCalled()
}
})

it('keeps native clip controls outside button semantics and lets them own keyboard events', () => {
Expand All @@ -210,7 +214,7 @@ describe('TimelineItem keyboard accessibility', () => {
expect(selectItems).not.toHaveBeenCalled()
})

it('semantically declines J, K, and L transport while the clip root is focused', () => {
it('routes J, K, and L transport while the clip root is focused', () => {
const { container } = render(
<PlaybackShortcutHarness>
<TimelineItem
Expand All @@ -230,9 +234,9 @@ describe('TimelineItem keyboard accessibility', () => {
dispatchKey(clip, 'l', 'KeyL')

expect(usePlaybackStore.getState()).toMatchObject({
isPlaying: false,
isPlaying: true,
playbackRate: 1,
transportMode: 'normal',
transportMode: 'shuttle',
})
})

Expand Down
1 change: 1 addition & 0 deletions src/features/timeline/deps/editor-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

export { useEditorCapability, useEditorHostContext } from '@/features/editor/host/context'
export { EditorHostProvider } from '@/features/editor/host/context-provider'
export type { EditorHost, EditorHistoryPort } from '@/features/editor/host/contract'
105 changes: 105 additions & 0 deletions src/features/timeline/hooks/shortcuts/use-tool-shortcuts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ const ITEM: VideoItem = {
src: 'clip.mp4',
}

const SECOND_ITEM: VideoItem = {
id: 'clip-2',
type: 'video',
trackId: TRACK.id,
from: 40,
durationInFrames: 40,
label: 'Clip 2',
src: 'clip-2.mp4',
}

function ShortcutHarness() {
useToolShortcuts({})
return null
Expand All @@ -61,6 +71,7 @@ describe('hover split and Razor shortcut ownership', () => {
beforeEach(() => {
registrations.calls = []
clearTimelineHover()
useSelectionStore.getState().clearSelection()
useSelectionStore.setState({ activeTool: 'select' })
useMicRecordingStore.setState({ status: 'idle' })
useTimelineCommandStore.getState().clearHistory()
Expand Down Expand Up @@ -111,6 +122,100 @@ describe('hover split and Razor shortcut ownership', () => {
expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1)
})

it('prefers the synchronously hovered clip over the selected clip and current frame', () => {
useTimelineStore.setState({ tracks: [TRACK], items: [ITEM, SECOND_ITEM], transitions: [] })
useSelectionStore.getState().selectItems([SECOND_ITEM.id])
usePlaybackStore.setState({ currentFrame: 55, previewFrame: 12, previewItemId: SECOND_ITEM.id })
setTimelineHover(ITEM.id, 25)
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

const items = useTimelineStore
.getState()
.items.toSorted((left, right) => left.from - right.from)
expect(items.map((item) => [item.from, item.durationInFrames])).toEqual([
[0, 25],
[25, 15],
[40, 40],
])
})

it('falls back to the one selected clip at currentFrame when the pointer is away', () => {
useTimelineStore.setState({ tracks: [TRACK], items: [ITEM, SECOND_ITEM], transitions: [] })
useSelectionStore.getState().selectItems([SECOND_ITEM.id])
usePlaybackStore.setState({ currentFrame: 55, previewFrame: 12, previewItemId: ITEM.id })
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

const items = useTimelineStore
.getState()
.items.toSorted((left, right) => left.from - right.from)
expect(items.map((item) => [item.from, item.durationInFrames])).toEqual([
[0, 40],
[40, 15],
[55, 25],
])
})

it.each([SECOND_ITEM.from, SECOND_ITEM.from + SECOND_ITEM.durationInFrames])(
'rejects a selected fallback at clip endpoint %s',
(currentFrame) => {
useTimelineStore.setState({ tracks: [TRACK], items: [ITEM, SECOND_ITEM], transitions: [] })
useSelectionStore.getState().selectItems([SECOND_ITEM.id])
usePlaybackStore.setState({ currentFrame, previewFrame: 12, previewItemId: ITEM.id })
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

expect(useTimelineStore.getState().items).toEqual([ITEM, SECOND_ITEM])
},
)

it('rejects the fallback when there is no selected clip', () => {
useTimelineStore.setState({ tracks: [TRACK], items: [ITEM, SECOND_ITEM], transitions: [] })
usePlaybackStore.setState({ currentFrame: 55, previewFrame: 12, previewItemId: SECOND_ITEM.id })
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

expect(useTimelineStore.getState().items).toEqual([ITEM, SECOND_ITEM])
})

it('rejects the fallback when multiple clips are selected', () => {
useTimelineStore.setState({ tracks: [TRACK], items: [ITEM, SECOND_ITEM], transitions: [] })
useSelectionStore.getState().selectItems([ITEM.id, SECOND_ITEM.id])
usePlaybackStore.setState({ currentFrame: 25, previewFrame: 12, previewItemId: ITEM.id })
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

expect(useTimelineStore.getState().items).toEqual([ITEM, SECOND_ITEM])
})

it('rejects a selected fallback whose item is missing', () => {
useSelectionStore.getState().selectItems(['missing-item'])
usePlaybackStore.setState({ currentFrame: 20, previewFrame: 12, previewItemId: ITEM.id })
render(<ShortcutHarness />)

act(() => {
getRegistration('SPLIT_AT_PLAYHEAD').callback({ preventDefault: vi.fn() })
})

expect(useTimelineStore.getState().items).toEqual([ITEM])
})

it('keeps Shift+C owned by the persistent Razor tool', () => {
render(<ShortcutHarness />)

Expand Down
Loading
Loading