diff --git a/DESIGN.md b/DESIGN.md
index 5bb4cf593..8c5d5e512 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -332,3 +332,17 @@ density and more color than the rest of the app because the color is data.
readable text to ~2.5–3.5:1. De-emphasize with size/weight, not sub-AA alpha. (Opacity
is fine on genuinely decorative markers or disabled controls, which AA exempts.)
- **Don't** add a light theme; contrast work happens within the dark ramp.
+
+## Editing workspace refresh
+
+CodePress keeps its independent AI conversation far left. FreeCut owns a separate
+Library (Transcript, Media, More) and Editor (preview above timeline). Columns
+resize and collapse independently, keep their mounted state, and expose restore
+controls outside hidden regions. Narrow workspaces retain readable minima and
+explicit visibility controls rather than combining Chat and Transcript into tabs.
+
+Settings and audio meters are on demand, with no permanent properties column.
+Common actions use a single header group. Transcript reading uses 14px text;
+quiet graphite surfaces, generous preview spacing, and restrained orange focus
+keep the footage central. Timeline interaction and host edit reliability have
+separate acceptance gates under quantfive/codepress#7144.
diff --git a/PRODUCT.md b/PRODUCT.md
index b0eb06fcf..9328dbcb6 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -6,8 +6,10 @@ product
## Users
-Experienced video editors. They come from Premiere Pro and DaVinci Resolve and
-expect those workflows: keyboard-driven, frame-accurate, dense panels they read
+Creators making a clear cut with AI assistance, alongside experienced video
+editors coming from Premiere Pro and DaVinci Resolve. Common actions must be
+discoverable through readable labels and progressive disclosure. Preserve expert
+workflows: keyboard-driven, frame-accurate, dense panels they read
at a glance. Their context is a focused editing session, often hours long, eyes
on the preview and timeline, hands on shortcuts. They want professional power
without an install, a subscription, or cloud uploads. The headline draw is that
diff --git a/package.json b/package.json
index 9600ad50c..f10223d5b 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"routes": "tsr generate",
"test": "vp test",
"test:run": "vp test run",
+ "test:layout-refresh": "playwright test --config playwright.layout-refresh.config.ts",
"test:responsive": "playwright test --config playwright.mobile.config.ts",
"test:editor-hardening": "vp test run src/features/timeline/components/timeline-content.test.tsx src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx src/features/timeline/stores/export-snapshot.test.ts src/features/export/components/export-dialog.test.tsx src/features/export/hooks/client-render-source.test.ts src/features/export/hooks/use-client-render.test.tsx src/features/preview/workers/consume-video-samples.test.ts src/features/preview/utils/media-resolver.test.ts src/features/preview/hooks/use-preview-media-resolution.test.tsx src/features/preview/components/source-composition.generation.test.tsx src/features/preview/components/video-preview.sync.test.tsx src/infrastructure/browser/blob-url-manager.test.ts",
"test:preview-sync": "vp test run src/features/preview/components/video-preview.sync.test.tsx",
diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md
index 4e485e835..4d5726c0b 100644
--- a/packages/freecut-editor/README.md
+++ b/packages/freecut-editor/README.md
@@ -173,3 +173,23 @@ lockfile:
```bash
npm install @quantfive/freecut-editor-surface@0.3.13
```
+
+## Three-column shell (pending next package release)
+
+`FREECUT_EDITOR_SHELL_VERSION = 1` identifies the optional `shell` prop. Hosts
+resolve the marker from the same module as the component. `headerActions` places
+the host history/export group in the project toolbar; `navigationActions` keeps
+the host Chat visibility control outside hidden columns; `transcriptActions`
+mounts generation/consent controls in Library without unmounting polling across
+tab switches. `onLayoutChange` reports the visible Library/Editor minimum width
+so the host can bound its own Chat separator. Existing callers need no props.
+
+The shell dispatches `freecut:cancel-timeline-gesture` on its own DOM root with
+`bubbles: true` before pausing playback and hiding Editor. The timeline consumer
+must accept only events whose target contains its own clip element and cancel
+through its existing preview cleanup path; see the companion timeline PR for
+that listener. Hiding a column never remounts its contents or commits a preview.
+
+Tracking: https://github.com/quantfive/codepress/issues/7144. Package publication
+and CodePress's pinned vendor-patch/static-asset reconciliation are separate
+integration steps. This source PR does not publish or bump a package version.
diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx
index f8da9cca0..f6b902add 100644
--- a/packages/freecut-editor/consumer-smoke.test.tsx
+++ b/packages/freecut-editor/consumer-smoke.test.tsx
@@ -3,7 +3,7 @@
import '@testing-library/jest-dom'
import '@quantfive/freecut-editor-surface/style.css'
-import { render, screen, waitFor, within } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'
import {
FreeCutEditorSurface,
@@ -127,7 +127,9 @@ describe('published FreeCut browser entry', () => {
{ timeout: 10_000 },
)
- expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument()
+ expect(screen.queryByTestId('properties-clip-panel-host')).not.toBeInTheDocument()
+ fireEvent.click(within(view.container).getByRole('button', { name: 'Canvas settings' }))
+ expect(await screen.findByTestId('properties-clip-panel-host')).toBeInTheDocument()
expect(await screen.findByTestId('caption-editor')).toBeInTheDocument()
expect(HOTKEYS).toMatchObject({
SHUTTLE_REVERSE: 'j',
diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts
index 4e4d145fc..4c2cd2551 100644
--- a/packages/freecut-editor/src/index.d.ts
+++ b/packages/freecut-editor/src/index.d.ts
@@ -526,7 +526,19 @@ export interface EditorHostProviderProps {
children: ReactNode
}
+export interface EditorShellOptions {
+ onLayoutChange?: (layout: {
+ minimumWidth: number
+ libraryVisible: boolean
+ editorVisible: boolean
+ }) => void
+ headerActions?: import('react').ReactNode
+ navigationActions?: import('react').ReactNode
+ transcriptActions?: import('react').ReactNode
+}
+
export interface FreeCutEditorSurfaceProps {
+ shell?: EditorShellOptions
host: EditorHost
}
@@ -557,3 +569,5 @@ export declare function isHostCapabilityEnabled(
capability: EditorCapability,
): boolean
export declare function createLocalEditorHost(options: LocalEditorHostOptions): EditorHost
+
+export declare const FREECUT_EDITOR_SHELL_VERSION = 1
diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts
index d94cf24f7..b29a843fd 100644
--- a/packages/freecut-editor/src/index.ts
+++ b/packages/freecut-editor/src/index.ts
@@ -57,3 +57,7 @@ export type {
MediaLocator,
ResolvedMediaLocator,
} from '@/features/editor/host/contract'
+
+export type { EditorShellOptions } from '@/features/editor/components/editor-workspace-shell'
+
+export const FREECUT_EDITOR_SHELL_VERSION = 1
diff --git a/playwright.layout-refresh.config.ts b/playwright.layout-refresh.config.ts
new file mode 100644
index 000000000..5a388dfa1
--- /dev/null
+++ b/playwright.layout-refresh.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'playwright/test'
+export default defineConfig({
+ testDir: './tests/browser',
+ testMatch: 'layout-refresh.spec.ts',
+ workers: 1,
+ reporter: 'line',
+ use: {
+ baseURL: 'http://127.0.0.1:4186',
+ channel: 'chrome',
+ headless: true,
+ viewport: { width: 1440, height: 900 },
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+ },
+})
diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json
index c08f6d559..ada5bc796 100644
--- a/provenance/dependency-inventory.json
+++ b/provenance/dependency-inventory.json
@@ -3,7 +3,7 @@
"generatedFrom": "package.json",
"packageName": "freecut",
"packageVersion": "0.0.0",
- "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
+ "packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538",
"lockfile": {
"path": "package-lock.json",
"lockfileVersion": 3,
diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json
index 3ee69326d..2171ac0ac 100644
--- a/provenance/freecut-baseline.json
+++ b/provenance/freecut-baseline.json
@@ -34,7 +34,7 @@
],
"dependencies": {
"packageJson": "package.json",
- "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
+ "packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538",
"lockfile": "package-lock.json",
"lockfileVersion": 3,
"lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e",
diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts
index cfdd7a238..2781495c6 100644
--- a/src/config/hotkeys-dom-guard.test.ts
+++ b/src/config/hotkeys-dom-guard.test.ts
@@ -87,6 +87,18 @@ describe('global shortcut DOM guards', () => {
})
})
+ it.each(['ArrowRight', 'ArrowLeft', ' ', 'Backspace', 'Delete'])(
+ 'leaves %s to a focused resize separator',
+ (key) => {
+ expect(
+ dispatchFrom('
', '#control', key),
+ ).toEqual({ captureSawEvent: true, defaultPrevented: false })
+ expect(
+ dispatchFrom('
', '#timeline', key),
+ ).toEqual({ captureSawEvent: true, defaultPrevented: true })
+ },
+ )
+
it('guards every dialog descendant, even when the target is a plain span', () => {
expect(
dispatchFrom('Message
', '#control', 'j'),
diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts
index d9c520624..77d2f3313 100644
--- a/src/config/hotkeys.ts
+++ b/src/config/hotkeys.ts
@@ -1234,6 +1234,7 @@ const INTERACTIVE_CONTROL_SELECTOR = [
'[role="treeitem"]',
'[role="slider"]',
'[role="scrollbar"]',
+ '[role="separator"]',
'[role="spinbutton"]',
'[role="textbox"]',
'[role="searchbox"]',
diff --git a/src/features/editor/components/editor-workspace-shell.test.tsx b/src/features/editor/components/editor-workspace-shell.test.tsx
new file mode 100644
index 000000000..9b49f5708
--- /dev/null
+++ b/src/features/editor/components/editor-workspace-shell.test.tsx
@@ -0,0 +1,164 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
+import { useEffect } from 'react'
+const { pause, sourcePause, setPendingPlay, cancel, actionMounts, actionUnmounts, selection } = vi.hoisted(() => ({
+ pause: vi.fn(),
+ sourcePause: vi.fn(),
+ setPendingPlay: vi.fn(),
+ cancel: vi.fn(),
+ actionMounts: vi.fn(),
+ actionUnmounts: vi.fn(),
+ selection: { selectedItemIds: ['clip-1'] },
+}))
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
+vi.mock('@/shared/state/playback', () => ({ usePlaybackStore: { getState: () => ({ pause }) } }))
+vi.mock('@/shared/state/source-player', () => ({
+ useSourcePlayerStore: {
+ getState: () => ({ playerMethods: { pause: sourcePause }, setPendingPlay }),
+ },
+}))
+vi.mock('@/shared/state/selection', () => ({
+ useSelectionStore: (select: (s: typeof selection) => unknown) => select(selection),
+}))
+vi.mock('./media-sidebar', () => ({
+ MediaSidebar: ({ transcriptActions }: { transcriptActions: React.ReactNode }) => (
+ <>
+
+ {transcriptActions}
+ >
+ ),
+}))
+vi.mock('./properties-sidebar', () => ({
+ PropertiesSidebar: () => ,
+}))
+vi.mock('./audio-meter-panel', () => ({ AudioMeterPanel: () => Meter
}))
+import { EditorWorkspaceShell } from './editor-workspace-shell'
+class ResizeObserverMock {
+ observe() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', ResizeObserverMock)
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ selection.selectedItemIds = ['clip-1']
+})
+function PollingActions() {
+ useEffect(() => {
+ actionMounts()
+ return actionUnmounts
+ }, [])
+ return Consent pending
+}
+
+describe('editor workspace columns', () => {
+ it('retains search, pending work and timeline while each column hides; cancels before hiding', () => {
+ const onLayoutChange = vi.fn()
+ const { container } = render(
+ }}>
+
+ ,
+ )
+ const search = screen.getByLabelText('Transcript search') as HTMLInputElement
+ fireEvent.change(search, { target: { value: 'keep my search' } })
+ const editor = container.querySelector('[data-editor-column="editor"]')!
+ const listener = (event: Event) => {
+ expect(editor.style.display).toBe('flex')
+ expect(event.target).toBe(container.firstElementChild)
+ cancel()
+ }
+ window.addEventListener('freecut:cancel-timeline-gesture', listener)
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.hideLibrary' }))
+ expect(onLayoutChange).toHaveBeenLastCalledWith({
+ minimumWidth: 480,
+ libraryVisible: false,
+ editorVisible: true,
+ })
+ sourcePause.mockImplementationOnce(() => {
+ expect(editor.style.display).toBe('flex')
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(pause).toHaveBeenCalledOnce()
+ expect(setPendingPlay).toHaveBeenCalledWith(false)
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.hideEditor' }))
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(pause).toHaveBeenCalledOnce()
+ expect(sourcePause).toHaveBeenCalledOnce()
+ expect(editor.style.display).toBe('none')
+ expect(actionMounts).toHaveBeenCalledOnce()
+ expect(actionUnmounts).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.showLibrary' }))
+ expect(onLayoutChange).toHaveBeenLastCalledWith({
+ minimumWidth: 260,
+ libraryVisible: true,
+ editorVisible: false,
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.showEditor' }))
+ expect(search.value).toBe('keep my search')
+ expect(screen.getByLabelText('Timeline state')).toBeTruthy()
+ expect(onLayoutChange).toHaveBeenLastCalledWith({
+ minimumWidth: 748,
+ libraryVisible: true,
+ editorVisible: true,
+ })
+ window.removeEventListener('freecut:cancel-timeline-gesture', listener)
+ })
+ it('closes stale clip settings and restores trigger focus', () => {
+ const { rerender } = render(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.clipSettings' }))
+ expect(screen.getByRole('region', { name: 'editor.refresh.settings' })).toBeTruthy()
+ selection.selectedItemIds = []
+ rerender(
+
+
+ ,
+ )
+ expect(screen.queryByRole('region', { name: 'editor.refresh.settings' })).toBeNull()
+ expect(document.activeElement).toBe(
+ screen.getByRole('button', { name: 'editor.refresh.canvasSettings' }),
+ )
+ })
+ it('closes stale settings without stealing focus from the host chat', () => {
+ const workspace = (
+ <>
+
+
+
+
+ >
+ )
+ const { rerender } = render(workspace)
+ fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.clipSettings' }))
+ const chat = screen.getByRole('textbox', { name: 'Host chat' })
+ chat.focus()
+ selection.selectedItemIds = []
+ rerender(
+ <>
+
+
+
+
+ >,
+ )
+ expect(screen.queryByRole('region', { name: 'editor.refresh.settings' })).toBeNull()
+ expect(document.activeElement).toBe(chat)
+ fireEvent.change(chat, { target: { value: 'continue the draft' } })
+ expect(chat).toHaveValue('continue the draft')
+ })
+
+ it('resizes with keyboard without resetting the reading state', () => {
+ render(
+
+
+ ,
+ )
+ const separator = screen.getByRole('separator')
+ fireEvent.keyDown(separator, { key: 'ArrowRight' })
+ expect(separator.getAttribute('aria-valuenow')).toBe('296')
+ })
+})
diff --git a/src/features/editor/components/editor-workspace-shell.tsx b/src/features/editor/components/editor-workspace-shell.tsx
new file mode 100644
index 000000000..f1e05025f
--- /dev/null
+++ b/src/features/editor/components/editor-workspace-shell.tsx
@@ -0,0 +1,269 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Settings2, X } from 'lucide-react'
+import { usePlaybackStore } from '@/shared/state/playback'
+import { useSelectionStore } from '@/shared/state/selection'
+import { useSourcePlayerStore } from '@/shared/state/source-player'
+import { MediaSidebar } from './media-sidebar'
+import { AudioMeterPanel } from './audio-meter-panel'
+import { PropertiesSidebar } from './properties-sidebar'
+
+/** Host-owned actions stay outside the independently hidden columns. */
+export interface EditorShellOptions {
+ onLayoutChange?: (layout: {
+ minimumWidth: number
+ libraryVisible: boolean
+ editorVisible: boolean
+ }) => void
+ headerActions?: ReactNode
+ navigationActions?: ReactNode
+ transcriptActions?: ReactNode
+}
+
+export function EditorWorkspaceShell({
+ children,
+ options,
+}: {
+ children: ReactNode
+ options?: EditorShellOptions
+}) {
+ const { t } = useTranslation()
+ const [availableWidth, setAvailableWidth] = useState(280)
+ const [settingsNotice, setSettingsNotice] = useState(false)
+ const settingsSelection = useRef([])
+ const [libraryVisible, setLibraryVisible] = useState(true)
+ const [editorVisible, setEditorVisible] = useState(true)
+ const [libraryWidth, setLibraryWidth] = useState(280)
+ const [metersOpen, setMetersOpen] = useState(false)
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const libraryToggleRef = useRef(null)
+ const settingsTrigger = useRef(null)
+ const settingsPanel = useRef(null)
+ const selectedIds = useSelectionStore((s) => s.selectedItemIds)
+ const rootRef = useRef(null)
+ useEffect(() => {
+ const root = rootRef.current
+ if (!root) return
+ const observer = new ResizeObserver(([entry]) => {
+ if (entry) setAvailableWidth(entry.contentRect.width)
+ })
+ observer.observe(root)
+ return () => observer.disconnect()
+ }, [])
+ useEffect(() => {
+ if (settingsOpen && settingsSelection.current.some((id) => !selectedIds.includes(id))) {
+ setSettingsOpen(false)
+ setSettingsNotice(true)
+ if (settingsPanel.current?.contains(document.activeElement)) {
+ settingsTrigger.current?.focus()
+ }
+ }
+ }, [selectedIds, settingsOpen])
+ const onLayoutChange = options?.onLayoutChange
+ useEffect(() => {
+ onLayoutChange?.({
+ minimumWidth: libraryVisible ? (editorVisible ? 748 : 260) : editorVisible ? 480 : 260,
+ libraryVisible,
+ editorVisible,
+ })
+ }, [libraryVisible, editorVisible, onLayoutChange])
+ const displayedLibraryWidth = editorVisible
+ ? Math.max(260, Math.min(libraryWidth, availableWidth - 488))
+ : availableWidth
+ const drag = useRef<{ x: number; width: number } | null>(null)
+ useEffect(() => {
+ drag.current = null
+ }, [libraryVisible, editorVisible])
+ const clampWidth = (width: number) => Math.max(260, Math.min(480, width))
+ const toggleEditor = () => {
+ if (editorVisible) {
+ rootRef.current?.dispatchEvent(
+ new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }),
+ )
+ usePlaybackStore.getState().pause()
+ const sourcePlayer = useSourcePlayerStore.getState()
+ sourcePlayer.setPendingPlay(false)
+ sourcePlayer.playerMethods?.pause()
+ }
+ setEditorVisible(!editorVisible)
+ }
+ const closeSettings = () => {
+ setSettingsOpen(false)
+ settingsTrigger.current?.focus()
+ }
+ return (
+
+
+ {options?.navigationActions}
+
+
+ {t('editor.refresh.view')}
+
+
+ setMetersOpen(!metersOpen)}
+ >
+ {t('editor.refresh.audioMeters')}
+
+
+
+ setLibraryVisible(!libraryVisible)}
+ >
+ {t(libraryVisible ? 'editor.refresh.hideLibrary' : 'editor.refresh.showLibrary')}
+
+
+ {t(editorVisible ? 'editor.refresh.hideEditor' : 'editor.refresh.showEditor')}
+
+ {
+ settingsSelection.current = [...selectedIds]
+ setSettingsNotice(false)
+ setEditorVisible(true)
+ setSettingsOpen(!settingsOpen)
+ }}
+ aria-expanded={settingsOpen}
+ >
+
+ {t(selectedIds.length ? 'editor.refresh.clipSettings' : 'editor.refresh.canvasSettings')}
+
+
+
+
+ {
+ setLibraryVisible(false)
+ libraryToggleRef.current?.focus()
+ }}
+ transcriptActions={options?.transcriptActions}
+ />
+
+ {libraryVisible && editorVisible && (
+
{
+ drag.current = { x: event.clientX, width: libraryWidth }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }}
+ onPointerMove={(event) => {
+ if (drag.current)
+ setLibraryWidth(clampWidth(drag.current.width + event.clientX - drag.current.x))
+ }}
+ onPointerUp={() => {
+ drag.current = null
+ }}
+ onPointerCancel={() => {
+ drag.current = null
+ }}
+ onLostPointerCapture={() => {
+ drag.current = null
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+ event.preventDefault()
+ setLibraryWidth(clampWidth(libraryWidth + (event.key === 'ArrowRight' ? 16 : -16)))
+ }
+ }}
+ />
+ )}
+
+ {settingsNotice && (
+
+ {t('editor.refresh.selectionChanged')}
+
+ )}
+ {children}
+ {metersOpen && (
+
+ )}
+ {settingsOpen && (
+ {
+ if (event.key === 'Escape') {
+ event.stopPropagation()
+ closeSettings()
+ }
+ }}
+ >
+
+
+ {t(
+ selectedIds.length
+ ? 'editor.refresh.clipSettings'
+ : 'editor.refresh.canvasSettings',
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {!libraryVisible && !editorVisible && (
+
+ {t('editor.refresh.restoreHint')}
+
+ )}
+
+
+ )
+}
diff --git a/src/features/editor/components/editor.test.tsx b/src/features/editor/components/editor.test.tsx
index b3c35a122..18e048ec0 100644
--- a/src/features/editor/components/editor.test.tsx
+++ b/src/features/editor/components/editor.test.tsx
@@ -438,7 +438,7 @@ describe('LoadedEditor migration metadata refresh', () => {
expect(mocks.resizablePanelGroup).toHaveBeenCalledWith(
expect.objectContaining({
- autoSaveId: 'editor:timeline-layout',
+ autoSaveId: 'editor:timeline-layout-refresh',
direction: 'vertical',
}),
)
@@ -497,6 +497,8 @@ describe('LoadedEditor migration metadata refresh', () => {
expect(screen.getByTestId('motion-preview-area')).toBeInTheDocument()
expect(screen.queryByTestId('timeline')).not.toBeInTheDocument()
expect(screen.getByTestId('media-sidebar')).toBeInTheDocument()
+ expect(screen.queryByTestId('properties-sidebar')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Canvas settings' }))
expect(screen.getByTestId('properties-sidebar')).toBeInTheDocument()
})
})
@@ -589,7 +591,7 @@ describe('editor responsive shell', () => {
afterEach(() => cleanup())
- it('starts 390px standalone with core surfaces visible and persisted desktop panels untouched', async () => {
+ it('keeps independent columns at 390px without changing persisted desktop panels', async () => {
mocks.editorWidth = 390
render(
)
@@ -597,8 +599,8 @@ describe('editor responsive shell', () => {
const shell = screen.getByRole('application')
await waitFor(() => expect(shell).toHaveAttribute('data-editor-layout', 'mobile'))
expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'true')
- expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'true')
- expect(screen.queryByTestId('media-sidebar')).not.toBeInTheDocument()
+ expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'false')
+ expect(screen.getByTestId('media-sidebar')).toBeVisible()
expect(screen.queryByTestId('properties-sidebar')).not.toBeInTheDocument()
expect(screen.queryByTestId('audio-meter-panel')).not.toBeInTheDocument()
expect(mocks.setLeftSidebarOpen).not.toHaveBeenCalled()
@@ -606,33 +608,25 @@ describe('editor responsive shell', () => {
expect(mocks.syncSidebarLayout).not.toHaveBeenCalled()
})
- it('opens each 390px panel as a focus-restoring drawer and closes it with Escape', async () => {
+ it('offers explicit column collapse and focus-restoring settings at 390px', async () => {
mocks.editorWidth = 390
render(
)
- await waitFor(() =>
- expect(screen.getByRole('application')).toHaveAttribute('data-editor-layout', 'mobile'),
- )
-
- for (const name of ['Media', 'Properties', 'Meters']) {
- const trigger = screen.getByRole('button', { name })
- trigger.focus()
- fireEvent.click(trigger)
-
- const drawer = await screen.findByRole('dialog', { name })
- expect(drawer).toBeInTheDocument()
- expect(drawer).toContainElement(document.activeElement as HTMLElement)
- const expectedTestId =
- name === 'Media'
- ? 'media-sidebar'
- : name === 'Properties'
- ? 'properties-sidebar'
- : 'audio-meter-panel'
- expect(screen.getByTestId(expectedTestId)).toHaveAttribute('data-mobile-drawer', 'true')
-
- fireEvent.keyDown(document, { key: 'Escape' })
- await waitFor(() => expect(screen.queryByRole('dialog', { name })).not.toBeInTheDocument())
- await waitFor(() => expect(trigger).toHaveFocus())
- }
+ await screen.findByTestId('timeline')
+ fireEvent.click(screen.getByRole('button', { name: 'Hide Library' }))
+ expect(screen.getByTestId('media-sidebar')).not.toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: 'Show Library' }))
+ expect(screen.getByTestId('media-sidebar')).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: 'Hide Editor' }))
+ expect(screen.getByTestId('timeline')).not.toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: 'Show Editor' }))
+ expect(screen.getByTestId('timeline')).toBeVisible()
+ const trigger = screen.getByRole('button', { name: 'Canvas settings' })
+ fireEvent.click(trigger)
+ const settings = screen.getByRole('region', { name: 'Settings' })
+ expect(screen.getByTestId('properties-sidebar')).toBeVisible()
+ fireEvent.keyDown(settings, { key: 'Escape' })
+ expect(screen.queryByRole('region', { name: 'Settings' })).not.toBeInTheDocument()
+ expect(trigger).toHaveFocus()
})
it('uses the same 390px layout inside a definite-height host surface', async () => {
@@ -654,11 +648,11 @@ describe('editor responsive shell', () => {
expect(screen.getByRole('application')).toHaveAttribute('data-editor-layout', 'mobile'),
)
expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'true')
- expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'true')
+ expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'false')
expect(hostRuntime.mountStores).toHaveBeenCalledTimes(1)
})
- it('preserves the existing 320/288/84 desktop layout branch at 1440px', async () => {
+ it('shows the desktop editor with library and on-demand settings and meters at 1440px', async () => {
render(
)
const shell = screen.getByRole('application')
@@ -666,7 +660,7 @@ describe('editor responsive shell', () => {
expect(screen.getByTestId('toolbar')).toHaveAttribute('data-compact', 'false')
expect(await screen.findByTestId('timeline')).toHaveAttribute('data-compact', 'false')
expect(screen.getByTestId('media-sidebar')).toHaveAttribute('data-mobile-drawer', 'false')
- expect(screen.getByTestId('properties-sidebar')).toHaveAttribute('data-mobile-drawer', 'false')
- expect(screen.getByTestId('audio-meter-panel')).toHaveAttribute('data-mobile-drawer', 'false')
+ expect(screen.queryByTestId('properties-sidebar')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('audio-meter-panel')).not.toBeInTheDocument()
})
})
diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx
index c22c7a4cd..bb5dd05c1 100644
--- a/src/features/editor/components/editor.tsx
+++ b/src/features/editor/components/editor.tsx
@@ -8,6 +8,7 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/componen
import { ErrorBoundary } from '@/app/error-boundary'
import { Toolbar } from './toolbar'
import { MediaSidebar } from './media-sidebar'
+import { EditorWorkspaceShell, type EditorShellOptions } from './editor-workspace-shell'
import { PropertiesSidebar } from './properties-sidebar'
import { PreviewArea } from './preview-area'
import { MotionPreviewArea, MotionTimelineDock } from './compose-workspace/compose-layout'
@@ -195,6 +196,7 @@ interface EditorProps {
currentSchemaVersion: number
requiresUpgrade: boolean
}
+ shell?: EditorShellOptions
hostRuntime?: LoadedEditorHostRuntime
onNavigateBack?: () => void
onRefreshMigration?: () => Promise
@@ -455,6 +457,7 @@ export const LoadedEditor = memo(function LoadedEditor({
hostRuntime,
onNavigateBack,
onRefreshMigration,
+ shell,
}: EditorProps) {
const { t } = useTranslation()
const [exportDialogOpen, setExportDialogOpen] = useState(false)
@@ -819,145 +822,184 @@ export const LoadedEditor = memo(function LoadedEditor({
onOpenRenderQueue={hostRuntime ? undefined : handleOpenRenderQueue}
renderQueueCount={hostRuntime ? 0 : renderQueueActiveCount}
compact={compact}
+ headerActions={shell?.headerActions}
/>
- {compact && (
-
- )}
-
- {/* Main Layout: Full-height sidebar + vertical split */}
-
- {/* Left Sidebar - Media Library (full column mode) */}
- {!compact && mediaFullColumn && !hidesDefaultSidebars && (
-
-
-
-
-
- )}
-
- {/* Right side: Preview/Properties + Timeline */}
- {isColorWorkspace ? (
-
- ) : (
+ {!isColorWorkspace ? (
+
- {/* Top - Preview + Properties (inline mode) */}
-
-
- {/* Left Sidebar - Media Library (inline with preview) */}
- {!compact && !mediaFullColumn && (
-
-
-
-
-
- )}
-
- {/* Center - Preview */}
+
+
{isMotionWorkspace ? (
) : (
-
+
)}
-
- {/* Right Sidebar - Properties (inline with preview) */}
- {!compact && !propertiesFullColumn && (
-
-
-
-
-
- )}
-
-
+
+
+
+ {isMotionWorkspace ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ ) : (
+ <>
+ {compact && (
+
+ )}
- {/* Bottom - Timeline */}
-
-
+ {/* Main Layout: Full-height sidebar + vertical split */}
+
+ {/* Left Sidebar - Media Library (full column mode) */}
+ {!compact && mediaFullColumn && !hidesDefaultSidebars && (
+
-
-
+
+
+
+ )}
+
+ {/* Right side: Preview/Properties + Timeline */}
+ {isColorWorkspace ? (
+
+ ) : (
+
+ {/* Top - Preview + Properties (inline mode) */}
+
+
+ {/* Left Sidebar - Media Library (inline with preview) */}
+ {!compact && !mediaFullColumn && (
+
+
+
+
+
+ )}
+
+ {/* Center - Preview */}
+
{isMotionWorkspace ? (
-
+
) : (
-
-
-
+
)}
-
- {!compact && }
+
+
+ {/* Right Sidebar - Properties (inline with preview) */}
+ {!compact && !propertiesFullColumn && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Bottom - Timeline */}
+
+
+
+
+
+ {isMotionWorkspace ? (
+
+ ) : (
+
+
+
+ )}
+
+ {!compact &&
}
+
+
+
+
+
+ )}
+
+ {/* Right Sidebar - Properties (full column mode) */}
+ {!compact && propertiesFullColumn && !hidesDefaultSidebars && (
+
+
+
-
-
- )}
-
- {/* Right Sidebar - Properties (full column mode) */}
- {!compact && propertiesFullColumn && !hidesDefaultSidebars && (
-
-
-
-
-
- )}
-
+ )}
+
- {compact && (
- {
- useEditorStore.getState().setSourcePreviewMediaId(null)
- closeMobilePanel()
- }}
- />
+ {compact && (
+ {
+ useEditorStore.getState().setSourcePreviewMediaId(null)
+ closeMobilePanel()
+ }}
+ />
+ )}
+ >
)}
{!hostRuntime && (
diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx
index edd8ce02b..32008c4b1 100644
--- a/src/features/editor/components/media-sidebar.tsx
+++ b/src/features/editor/components/media-sidebar.tsx
@@ -290,6 +290,8 @@ const DEFAULT_TEXT_TEMPLATE_LABEL = 'Text'
const ADD_TEXT_TEMPLATE_LABEL = 'Add Text'
interface MediaSidebarProps {
+ shellWidth?: number
+ transcriptActions?: React.ReactNode
mobileDrawer?: boolean
onRequestClose?: () => void
}
@@ -299,6 +301,7 @@ function resolveMediaSidebarPresentation({
leftSidebarOpen,
sidebarWidth,
drawerWidth,
+ shellWidth,
onRequestClose,
toggleLeftSidebar,
}: {
@@ -306,13 +309,14 @@ function resolveMediaSidebarPresentation({
leftSidebarOpen: boolean
sidebarWidth: number
drawerWidth: number
+ shellWidth?: number
onRequestClose?: () => void
toggleLeftSidebar: () => void
}) {
- if (mobileDrawer) {
+ if (mobileDrawer || shellWidth !== undefined) {
return {
panelOpen: true,
- panelWidth: drawerWidth,
+ panelWidth: shellWidth ?? drawerWidth,
mode: 'drawer' as const,
collapsePanel: onRequestClose ?? toggleLeftSidebar,
}
@@ -406,9 +410,109 @@ function MediaSidebarResizeHandle({
)
}
+function TranscriptSidebarContent({
+ activeTab,
+ transcriptActivated,
+ hostMode,
+ transcriptActions,
+}: {
+ activeTab: EditorSidebarTab
+ transcriptActivated: boolean
+ hostMode: boolean
+ transcriptActions?: React.ReactNode
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {transcriptActions && (
+
+
+ {t('editor.refresh.generateTranscript')}
+
+ {transcriptActions}
+
+ )}
+ {transcriptActivated && hostMode ? (
+
+ ) : transcriptActivated ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+function LibraryToolNavigation({
+ categories: visibleCategories,
+ activeTab,
+ setActiveTab,
+ onClose: onRequestClose,
+}: {
+ categories: { id: EditorSidebarTab; label: string }[]
+ activeTab: EditorSidebarTab
+ setActiveTab: (tab: EditorSidebarTab) => void
+ onClose?: () => void
+}) {
+ const { t } = useTranslation()
+ return (
+
+ {visibleCategories
+ .filter(({ id }) => id === 'transcript' || id === 'media')
+ .sort((a) => (a.id === 'transcript' ? -1 : 1))
+ .map(({ id, label }) => (
+ setActiveTab(id)}
+ >
+ {label}
+
+ ))}
+
+ {t('editor.refresh.moreTools')}
+ {
+ if (event.target.value) setActiveTab(event.target.value as EditorSidebarTab)
+ }}
+ >
+ {t('editor.refresh.more')}
+ {visibleCategories
+ .filter(({ id }) => id !== 'transcript' && id !== 'media')
+ .map(({ id, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
export const MediaSidebar = memo(function MediaSidebar({
mobileDrawer = false,
onRequestClose,
+ shellWidth,
+ transcriptActions,
}: MediaSidebarProps) {
const { t } = useTranslation()
const hostMode = useEditorHostMode()
@@ -424,11 +528,18 @@ export const MediaSidebar = memo(function MediaSidebar({
const toggleMediaFullColumn = useEditorStore((s) => s.toggleMediaFullColumn)
const activeTab = useEditorStore((s) => s.activeTab)
const setActiveTab = useEditorStore((s) => s.setActiveTab)
+ const initializedShellTab = useRef(false)
+ useEffect(() => {
+ if (shellWidth === undefined || initializedShellTab.current) return
+ initializedShellTab.current = true
+ if (!hostMode || (canTranscribe && host?.transcript)) setActiveTab('transcript')
+ }, [shellWidth, hostMode, canTranscribe, host?.transcript, setActiveTab])
const sidebarWidth = useEditorStore((s) => s.sidebarWidth)
const setSidebarWidth = useEditorStore((s) => s.setSidebarWidth)
const prefersReducedMotion = useReducedMotion()
const { panelOpen, panelWidth, mode, collapsePanel } = resolveMediaSidebarPresentation({
mobileDrawer,
+ shellWidth,
leftSidebarOpen,
sidebarWidth,
drawerWidth: editorLayout.leftSidebarDefaultWidth,
@@ -436,6 +547,10 @@ export const MediaSidebar = memo(function MediaSidebar({
toggleLeftSidebar,
})
+ const [transcriptActivated, setTranscriptActivated] = useState(activeTab === 'transcript')
+ useEffect(() => {
+ if (activeTab === 'transcript') setTranscriptActivated(true)
+ }, [activeTab])
const [aiTabActivated, setAiTabActivated] = useState(activeTab === 'ai')
// The Lottie panel hits an external API on mount, so keep it unmounted until
// the tab is first opened; it then stays mounted (state preserved).
@@ -737,56 +852,68 @@ export const MediaSidebar = memo(function MediaSidebar({
}, [])
return (
-
- {/* Vertical Category Bar */}
-
- {/* Header row - aligned with content panel header */}
-
-
+ {shellWidth !== undefined ? (
+
+ ) : (
+ <>
+ {/* Vertical Category Bar */}
+
- {panelOpen ? (
-
- ) : (
-
- )}
-
-
+ {/* Header row - aligned with content panel header */}
+
+
+ {panelOpen ? (
+
+ ) : (
+
+ )}
+
+
- {/* Category Icons */}
-
- {visibleCategories.map(({ id, icon: Icon, label }) => (
-
- selectMediaSidebarCategory({
- id,
- mobileDrawer,
- activeTab,
- leftSidebarOpen,
- setActiveTab,
- toggleLeftSidebar,
- triggerPreviews,
- })
- }
- className={`
+ {/* Category Icons */}
+
+ {visibleCategories.map(({ id, icon: Icon, label }) => (
+
+ selectMediaSidebarCategory({
+ id,
+ mobileDrawer,
+ activeTab,
+ leftSidebarOpen,
+ setActiveTab,
+ toggleLeftSidebar,
+ triggerPreviews,
+ })
+ }
+ className={`
w-9 h-9 rounded-lg flex items-center justify-center transition-[transform,background-color,color] duration-150 active:scale-95
${
activeTab === id && panelOpen
@@ -794,15 +921,16 @@ export const MediaSidebar = memo(function MediaSidebar({
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}
`}
- data-tooltip={label}
- data-tooltip-side="right"
- >
-
-
- ))}
-
-
-
+ data-tooltip={label}
+ data-tooltip-side="right"
+ >
+
+
+ ))}
+
+
+ >
+ )}
{/* Content Panel — width animated via motion for the open/close toggle.
We intentionally animate `width` (a layout property, not the cheaper
transform/opacity) because collapsing must reclaim layout space for the
@@ -812,7 +940,7 @@ export const MediaSidebar = memo(function MediaSidebar({
a touch faster than open (exit < entrance). During a resize-drag we snap
(duration 0) so width tracks the pointer instead of easing behind it. */}
{/* Panel Header — sits with the tab content */}
@@ -1280,17 +1409,12 @@ export const MediaSidebar = memo(function MediaSidebar({
{/* Transcript Tab */}
-
- {activeTab === 'transcript' && hostMode ? (
-
- ) : activeTab === 'transcript' ? (
-
-
-
- ) : null}
-
+
{/* AI Tab */}
{/* Resize Handle */}
diff --git a/src/features/editor/components/toolbar.tsx b/src/features/editor/components/toolbar.tsx
index dd79c768e..d0ec91386 100644
--- a/src/features/editor/components/toolbar.tsx
+++ b/src/features/editor/components/toolbar.tsx
@@ -71,6 +71,7 @@ function LocalInferenceToolbarStatus({ hostMode }: { hostMode: boolean }) {
}
interface ToolbarProps {
+ headerActions?: React.ReactNode
projectId: string
project: {
id: string
@@ -112,6 +113,7 @@ interface MobileToolbarProps extends ToolbarProps {
// fallow-ignore-next-line complexity
function MobileToolbar({
project,
+ headerActions,
onBack,
onSave,
onExport,
@@ -185,6 +187,7 @@ function MobileToolbar({
)}
+ {headerActions}
{(onExport || onExportBundle) && (
@@ -290,6 +293,44 @@ function requestToolbarBack({
if (onSave && onBack) showUnsavedDialog()
}
+function ToolbarSaveAction({
+ onSave,
+ handleSave,
+ isSaveAnimating,
+ saveAnimationKey,
+}: {
+ onSave?: () => Promise
+ handleSave: () => Promise
+ isSaveAnimating: boolean
+ saveAnimationKey: number
+}) {
+ const { t } = useTranslation()
+ return (
+ <>
+ {onSave && (
+
+
+ {isSaveAnimating ? (
+
+ ) : (
+
+ )}
+
+
+ {t('toolbar.save')}
+
+ )}
+ >
+ )
+}
+
export const Toolbar = memo(function Toolbar({
projectId,
project,
@@ -300,6 +341,7 @@ export const Toolbar = memo(function Toolbar({
onOpenRenderQueue,
renderQueueCount = 0,
compact = false,
+ headerActions,
}: ToolbarProps) {
const { t } = useTranslation()
const hostMode = useEditorHostMode()
@@ -382,6 +424,7 @@ export const Toolbar = memo(function Toolbar({
if (compact) {
return (
-
+
-
-
+
+
{project?.name || t('common.untitledProject')}
-
+
{t('toolbar.specsDetailed', {
width: project?.width,
height: project?.height,
@@ -478,110 +521,105 @@ export const Toolbar = memo(function Toolbar({
)}
- {/* Socials */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Utility */}
-
-
-
-
-
-
-
- {hasUnseenWhatsNew && (
-
- )}
-
- setShowSettingsDialog(true)}
- disabled={hostMode}
- data-tooltip={t('toolbar.settings')}
- data-tooltip-side="bottom"
- aria-label={t('toolbar.settings')}
- >
-
-
- setShowShortcutsDialog(true)}
- data-tooltip={t('toolbar.keyboardShortcuts')}
- data-tooltip-side="bottom"
- aria-label={t('toolbar.keyboardShortcutsAria')}
- >
-
-
-
+
+
+ {t('editor.refresh.helpSettings')}
+
+
+ {/* Socials */}
+
+
+
+
+
+
+
+
+
+
-
+
+
+ {/* Utility */}
+
+
+
+
+
+
+
+ {hasUnseenWhatsNew && (
+
+ )}
+
+
setShowSettingsDialog(true)}
+ disabled={hostMode}
+ data-tooltip={t('toolbar.settings')}
+ data-tooltip-side="bottom"
+ aria-label={t('toolbar.settings')}
+ >
+
+
+
setShowShortcutsDialog(true)}
+ data-tooltip={t('toolbar.keyboardShortcuts')}
+ data-tooltip-side="bottom"
+ aria-label={t('toolbar.keyboardShortcutsAria')}
+ >
+
+
+
- {/* Actions */}
-
-
- {isSaveAnimating ? (
-
- ) : (
-
- )}
-
+
- {t('toolbar.save')}
-
+
+ {headerActions}
+ {/* Actions */}
+
{onOpenRenderQueue && (
(null)
const [error, setError] = useState(null)
@@ -151,6 +158,7 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) {
currentSchemaVersion: 1,
requiresUpgrade: false,
}}
+ shell={shell}
hostRuntime={state.runtime}
onNavigateBack={onNavigateBack}
/>
diff --git a/src/features/keyframes/components/dopesheet-editor/dopesheet-graph-pane.tsx b/src/features/keyframes/components/dopesheet-editor/dopesheet-graph-pane.tsx
index b033a57c6..eb9b36cdf 100644
--- a/src/features/keyframes/components/dopesheet-editor/dopesheet-graph-pane.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/dopesheet-graph-pane.tsx
@@ -56,6 +56,7 @@ interface DopesheetGraphPaneProps {
onScrubEnd?: () => void
onDragStart?: () => void
onDragEnd?: () => void
+ onDragCancel?: () => void
onAddKeyframe?: (property: AnimatableProperty, frame: number) => void
onRemoveKeyframes?: (refs: KeyframeRef[]) => void
onNavigateToKeyframe?: (frame: number) => void
@@ -112,6 +113,7 @@ export function DopesheetGraphPane({
onScrubEnd,
onDragStart,
onDragEnd,
+ onDragCancel,
onAddKeyframe,
onRemoveKeyframes,
onNavigateToKeyframe,
@@ -179,6 +181,7 @@ export function DopesheetGraphPane({
onScrubEnd={onScrubEnd}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
+ onDragCancel={onDragCancel}
onAddKeyframe={onAddKeyframe}
onRemoveKeyframes={onRemoveKeyframes}
onNavigateToKeyframe={onNavigateToKeyframe}
diff --git a/src/features/keyframes/components/dopesheet-editor/dopesheet-timeline-cells.tsx b/src/features/keyframes/components/dopesheet-editor/dopesheet-timeline-cells.tsx
index 9cb7322db..7f915206a 100644
--- a/src/features/keyframes/components/dopesheet-editor/dopesheet-timeline-cells.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/dopesheet-timeline-cells.tsx
@@ -328,6 +328,7 @@ interface PropertyTimelineCellProps {
onSegmentEasingChange?: SegmentEasingChange
onSegmentDragStart?: () => void
onSegmentDragEnd?: () => void
+ onSegmentDragCancel?: () => void
setKeyframeButtonRef: (keyframeId: string, node: HTMLButtonElement | null) => void
keyframeMetaByIdRef: MutableRefObject>
sheetPreviewFrames: Record | null
@@ -354,6 +355,7 @@ export const PropertyTimelineCell = memo(function PropertyTimelineCell({
onSegmentEasingChange,
onSegmentDragStart,
onSegmentDragEnd,
+ onSegmentDragCancel,
setKeyframeButtonRef,
keyframeMetaByIdRef,
sheetPreviewFrames,
@@ -456,6 +458,7 @@ export const PropertyTimelineCell = memo(function PropertyTimelineCell({
onChange={onSegmentEasingChange}
onDragStart={onSegmentDragStart}
onDragEnd={onSegmentDragEnd}
+ onDragCancel={onSegmentDragCancel}
/>
))}
diff --git a/src/features/keyframes/components/dopesheet-editor/easing-curve-editor.tsx b/src/features/keyframes/components/dopesheet-editor/easing-curve-editor.tsx
index bf912008d..21862fa58 100644
--- a/src/features/keyframes/components/dopesheet-editor/easing-curve-editor.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/easing-curve-editor.tsx
@@ -1,3 +1,7 @@
+import {
+ useKeyframeGestureCancellation,
+ releaseKeyframePointerCapture,
+} from '../use-keyframe-gesture-cancellation'
import {
useCallback,
useEffect,
@@ -144,6 +148,8 @@ interface EasingCurveEditorProps {
onChangeSpring: (spring: SpringParameters, commit: boolean) => void
onDragStart?: () => void
onDragEnd?: () => void
+ onDragCancel?: () => void
+ ownerRef: React.RefObject
}
export function EasingCurveEditor({
@@ -153,7 +159,37 @@ export function EasingCurveEditor({
onChangeSpring,
onDragStart,
onDragEnd,
+ onDragCancel,
+ ownerRef,
}: EasingCurveEditorProps) {
+ const activeGesture = useRef(false)
+ const captureRef = useRef<{ target: Element; pointerId: number } | null>(null)
+ const generation = useRef(0)
+ const [childGeneration, setChildGeneration] = useState(0)
+ const renderGeneration = generation.current
+ const isCurrent = useCallback(() => generation.current === renderGeneration, [renderGeneration])
+ const beginGesture = () => {
+ if (!isCurrent()) return
+ activeGesture.current = true
+ onDragStart?.()
+ }
+ const endGesture = () => {
+ if (!isCurrent() || !activeGesture.current) return
+ activeGesture.current = false
+ onDragEnd?.()
+ }
+ useKeyframeGestureCancellation(ownerRef, (updateReactState) => {
+ if (!activeGesture.current) return
+ activeGesture.current = false
+ generation.current += 1
+ const capture = captureRef.current
+ captureRef.current = null
+ if (capture) releaseKeyframePointerCapture(capture.target, capture.pointerId)
+ // Retire captured child callbacks immediately, then discard their pointer
+ // and draft state (including the slider library's internal capture).
+ if (updateReactState) setChildGeneration(generation.current)
+ onDragCancel?.()
+ })
const { t } = useTranslation()
const [duration, setDuration] = useState(BEZIER_PREVIEW_DURATION)
@@ -169,16 +205,16 @@ export function EasingCurveEditor({
const setBezierField = useCallback(
(key: BezierKey, raw: number, commit: boolean) => {
- onChangeBezier({ ...bezier, [key]: clampField(key, raw) }, commit)
+ if (isCurrent()) onChangeBezier({ ...bezier, [key]: clampField(key, raw) }, commit)
},
- [onChangeBezier, bezier],
+ [onChangeBezier, bezier, isCurrent],
)
const setSpringField = useCallback(
(key: SpringKey, raw: number, commit: boolean) => {
- onChangeSpring({ ...spring, [key]: clampSpringField(key, raw) }, commit)
+ if (isCurrent()) onChangeSpring({ ...spring, [key]: clampSpringField(key, raw) }, commit)
},
- [onChangeSpring, spring],
+ [onChangeSpring, spring, isCurrent],
)
// Drag a bezier control point (P1 from the start, P2 from the end) on the
@@ -189,13 +225,19 @@ export function EasingCurveEditor({
point === 'p1'
? { ...bezier, x1: clampField('x1', x), y1: clampField('y1', y) }
: { ...bezier, x2: clampField('x2', x), y2: clampField('y2', y) }
- onChangeBezier(next, commit)
+ if (isCurrent()) onChangeBezier(next, commit)
},
- [onChangeBezier, bezier],
+ [onChangeBezier, bezier, isCurrent],
)
return (
-
+
{
+ captureRef.current = { target: event.target as Element, pointerId: event.pointerId }
+ }}
+ >
{/* Square canvas, capped so the sliders keep a usable width; centered
vertically against the taller controls column. */}
@@ -203,8 +245,8 @@ export function EasingCurveEditor({
config={previewConfig}
editableBezier={isSpring ? undefined : bezier}
onBezierPointChange={isSpring ? undefined : setBezierPoint}
- onDragStart={onDragStart}
- onDragEnd={onDragEnd}
+ onDragStart={beginGesture}
+ onDragEnd={endGesture}
/>
@@ -221,8 +263,8 @@ export function EasingCurveEditor({
decimals={SPRING_FIELD_RANGE[key].decimals}
onLive={(v) => setSpringField(key, v, false)}
onCommit={(v) => setSpringField(key, v, true)}
- onDragStart={onDragStart}
- onDragEnd={onDragEnd}
+ onDragStart={beginGesture}
+ onDragEnd={endGesture}
/>
))
: BEZIER_INPUT_KEYS.map((key) => (
@@ -235,8 +277,8 @@ export function EasingCurveEditor({
step={0.01}
onLive={(v) => setBezierField(key, v, false)}
onCommit={(v) => setBezierField(key, v, true)}
- onDragStart={onDragStart}
- onDragEnd={onDragEnd}
+ onDragStart={beginGesture}
+ onDragEnd={endGesture}
/>
))}
{!isSpring && (
@@ -273,6 +315,7 @@ function CurveCanvas({
}) {
const svgRef = useRef
(null)
const [drag, setDrag] = useState<'p1' | 'p2' | null>(null)
+ const dragRef = useRef<'p1' | 'p2' | null>(null)
// While a handle is held, track the pointer on the window (not just the SVG)
// so the drag survives leaving the canvas; convert client px → bezier coords.
@@ -286,10 +329,13 @@ function CurveCanvas({
return { x: (vx - PAD) / PLOT, y: Y_MAX - ((vy - PAD) / PLOT) * (Y_MAX - Y_MIN) }
}
const move = (e: PointerEvent) => {
+ if (dragRef.current !== drag) return
const b = toBezier(e.clientX, e.clientY)
if (b) onBezierPointChange(drag, b.x, b.y, false)
}
const up = (e: PointerEvent) => {
+ if (dragRef.current !== drag) return
+ dragRef.current = null
const b = toBezier(e.clientX, e.clientY)
if (b) onBezierPointChange(drag, b.x, b.y, true)
setDrag(null)
@@ -310,6 +356,7 @@ function CurveCanvas({
const startDrag = (point: 'p1' | 'p2') => (e: ReactPointerEvent) => {
e.preventDefault()
onDragStart?.()
+ dragRef.current = point
setDrag(point)
}
diff --git a/src/features/keyframes/components/dopesheet-editor/index.tsx b/src/features/keyframes/components/dopesheet-editor/index.tsx
index e91e5e375..85b7882e7 100644
--- a/src/features/keyframes/components/dopesheet-editor/index.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/index.tsx
@@ -1,3 +1,7 @@
+import {
+ useKeyframeGestureCancellation,
+ releaseKeyframePointerCapture,
+} from '../use-keyframe-gesture-cancellation'
/**
* Dopesheet Editor - timeline-style keyframe editor.
* Shows keyframes across properties as draggable diamonds on a frame grid.
@@ -1127,6 +1131,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
const valueScrubRef = useRef<{
property: AnimatableProperty
pointerId: number
+ target: HTMLInputElement
startX: number
startValue: number
lastValue: number
@@ -2446,6 +2451,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
valueScrubRef.current = {
property,
+ target: event.currentTarget,
pointerId: event.pointerId,
startX: event.clientX,
startValue,
@@ -2495,6 +2501,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
const scrub = valueScrubRef.current
if (!scrub || scrub.pointerId !== event.pointerId || scrub.property !== property) return
valueScrubRef.current = null
+ releaseKeyframePointerCapture(scrub.target, scrub.pointerId)
if (!scrub.didDrag) return
event.preventDefault()
@@ -2510,21 +2517,32 @@ export const DopesheetEditor = memo(function DopesheetEditor({
[onDragEnd, onPropertyValueCommit, onPropertyValuePreview],
)
- const handleValueScrubCancel = useCallback(
- (event: React.PointerEvent, property: AnimatableProperty) => {
+ const cancelValueScrub = useCallback(
+ (updateReactState: boolean) => {
const scrub = valueScrubRef.current
- if (!scrub || scrub.pointerId !== event.pointerId || scrub.property !== property) return
+ if (!scrub) return
valueScrubRef.current = null
+ releaseKeyframePointerCapture(scrub.target, scrub.pointerId)
if (!scrub.didDrag) return
-
- event.preventDefault()
- const restoredDisplay = formatPropertyValue(property, scrub.startValue)
- valueDraftAtFocusRef.current[property] = restoredDisplay
- setValueDrafts((previous) => ({ ...previous, [property]: restoredDisplay }))
+ const restoredDisplay = formatPropertyValue(scrub.property, scrub.startValue)
+ valueDraftAtFocusRef.current[scrub.property] = restoredDisplay
+ // Blur caused by hiding must not commit the cancelled draft.
+ skipNextBlurCommitPropertyRef.current = scrub.property
+ if (updateReactState)
+ setValueDrafts((previous) => ({ ...previous, [scrub.property]: restoredDisplay }))
onDragCancel?.()
},
[formatPropertyValue, onDragCancel],
)
+ const handleValueScrubCancel = useCallback(
+ (event: React.PointerEvent, property: AnimatableProperty) => {
+ const scrub = valueScrubRef.current
+ if (!scrub || scrub.pointerId !== event.pointerId || scrub.property !== property) return
+ event.preventDefault()
+ cancelValueScrub(true)
+ },
+ [cancelValueScrub],
+ )
const nudgeSelectedKeyframes = useCallback(
(deltaFrames: number) => {
@@ -2673,6 +2691,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
)
const dragStateRef = useRef(null)
+ const dragCaptureRef = useRef(null)
const selectionAnchorByPropertyRef = useRef(new Map())
const { marqueeOverlayRef, getMarqueeModeFromPointerEvent, beginMarqueeSelection } =
@@ -2765,6 +2784,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
appliedDeltaFrames: 0,
}
scheduleDragPreviewFrames(null)
+ dragCaptureRef.current = event.currentTarget
setPointerCaptureSafely(event.currentTarget, event.pointerId)
},
@@ -2851,6 +2871,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
appliedDeltaFrames: 0,
}
scheduleDragPreviewFrames(null)
+ dragCaptureRef.current = event.currentTarget
setPointerCaptureSafely(event.currentTarget, event.pointerId)
},
@@ -2913,6 +2934,23 @@ export const DopesheetEditor = memo(function DopesheetEditor({
[beginMarqueeSelection, disabled, getMarqueeModeFromPointerEvent, selectedKeyframeIds],
)
+ const cancelDiamondDrag = useCallback(
+ (updateReactState: boolean) => {
+ const drag = dragStateRef.current
+ if (!drag) return
+ dragStateRef.current = null
+ const target = dragCaptureRef.current
+ dragCaptureRef.current = null
+ releaseKeyframePointerCapture(target, drag.pointerId)
+ if (updateReactState) scheduleDragPreviewFrames(null)
+ if (drag.started && !drag.duplicateOnCommit) {
+ onSelectionFrameDelta?.(drag.appliedDeltaFrames, 'cancel')
+ onDragCancel?.()
+ }
+ },
+ [onDragCancel, onSelectionFrameDelta, scheduleDragPreviewFrames],
+ )
+
useEffect(() => {
if (!onKeyframeMove && !onDuplicateKeyframes) return
@@ -2941,6 +2979,9 @@ export const DopesheetEditor = memo(function DopesheetEditor({
const handlePointerUp = (event: PointerEvent) => {
const dragState = dragStateRef.current
if (!dragState || dragState.pointerId !== event.pointerId) return
+ dragStateRef.current = null
+ releaseKeyframePointerCapture(dragCaptureRef.current, dragState.pointerId)
+ dragCaptureRef.current = null
if (dragState.started) {
const deltaFrames = getDopesheetDragDelta(
@@ -2962,24 +3003,19 @@ export const DopesheetEditor = memo(function DopesheetEditor({
onDragEnd?.()
}
}
- dragStateRef.current = null
scheduleDragPreviewFrames(null)
}
const handlePointerCancel = (event: PointerEvent) => {
const dragState = dragStateRef.current
if (!dragState || dragState.pointerId !== event.pointerId) return
- if (dragState.started && !dragState.duplicateOnCommit) {
- onSelectionFrameDelta?.(dragState.appliedDeltaFrames, 'cancel')
- onDragCancel?.()
- }
- dragStateRef.current = null
- scheduleDragPreviewFrames(null)
+ cancelDiamondDrag(true)
}
return addWindowPointerListeners(handlePointerMove, handlePointerUp, handlePointerCancel)
}, [
disabled,
+ cancelDiamondDrag,
buildSelectionFramePreview,
commitSelectionFramePreview,
duplicateSelectionFramePreview,
@@ -2997,6 +3033,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
])
const scrubPointerIdRef = useRef(null)
+ const rulerCaptureRef = useRef(null)
const rulerScrubActiveRef = useRef(false)
const rulerScrubHandoffFrameRef = useRef(null)
const [isRulerScrubbing, setIsRulerScrubbing] = useState(false)
@@ -3056,6 +3093,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
startScrub: startRulerScrub,
queueScrub: queueRulerScrub,
flushPendingScrub: flushPendingRulerScrub,
+ cancelPendingScrub: cancelPendingRulerScrub,
} = useCoalescedScrub(onScrub)
const getRulerScrubFrameFromClientX = useCallback(
(clientX: number) => {
@@ -3156,6 +3194,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
beginTimelineSkimmerScrub(skimmerScrubOwnerRef.current)
setIsRulerScrubbing(true)
scrubPointerIdRef.current = event.pointerId
+ rulerCaptureRef.current = event.currentTarget
rulerScrubClientXRef.current = event.clientX
rulerScrubViewportRef.current = viewport
rulerEdgeScrollTimestampRef.current = null
@@ -3264,6 +3303,30 @@ export const DopesheetEditor = memo(function DopesheetEditor({
],
)
+ const cancelRulerScrub = (updateReactState: boolean) => {
+ const pointerId = scrubPointerIdRef.current
+ scrubPointerIdRef.current = null
+ const target = rulerCaptureRef.current
+ rulerCaptureRef.current = null
+ cancelPendingRulerScrub()
+ if (rulerEdgeScrollRafRef.current !== null) cancelAnimationFrame(rulerEdgeScrollRafRef.current)
+ rulerEdgeScrollRafRef.current = null
+ rulerScrubClientXRef.current = null
+ rulerEdgeScrollTimestampRef.current = null
+ lastScrubbedFrameRef.current = null
+ rulerScrubActiveRef.current = false
+ if (pointerId === null) return
+ releaseKeyframePointerCapture(target, pointerId)
+ if (updateReactState) setIsRulerScrubbing(false)
+ onScrubEnd?.()
+ endTimelineSkimmerScrub(skimmerScrubOwnerRef.current)
+ }
+ useKeyframeGestureCancellation(pickWhipRootRef, (updateReactState) => {
+ cancelDiamondDrag(updateReactState)
+ cancelValueScrub(updateReactState)
+ cancelRulerScrub(updateReactState)
+ })
+
// Match the main timeline navigation model for standalone keyframe editors:
// - Ctrl/Cmd+wheel zooms the time axis about the cursor.
// - Plain wheel / trackpad swipe pans the time axis horizontally.
@@ -4758,6 +4821,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
onSegmentEasingChange={onSegmentEasingChange}
onSegmentDragStart={onDragStart}
onSegmentDragEnd={onDragEnd}
+ onSegmentDragCancel={onDragCancel}
setKeyframeButtonRef={setKeyframeButtonRef}
keyframeMetaByIdRef={keyframeMetaByIdRef}
sheetPreviewFrames={sheetPreviewFrames}
@@ -4799,6 +4863,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
keyframeMetaByIdRef,
itemId,
onSegmentEasingChange,
+ onDragCancel,
onDragStart,
onDragEnd,
presentation,
@@ -5015,6 +5080,7 @@ export const DopesheetEditor = memo(function DopesheetEditor({
onScrubEnd={onScrubEnd}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
+ onDragCancel={onDragCancel}
onAddKeyframe={onAddKeyframe}
onRemoveKeyframes={onRemoveKeyframes}
onNavigateToKeyframe={onNavigateToKeyframe}
diff --git a/src/features/keyframes/components/dopesheet-editor/segment-easing-popover.tsx b/src/features/keyframes/components/dopesheet-editor/segment-easing-popover.tsx
index a9327ad47..3335e24cf 100644
--- a/src/features/keyframes/components/dopesheet-editor/segment-easing-popover.tsx
+++ b/src/features/keyframes/components/dopesheet-editor/segment-easing-popover.tsx
@@ -81,6 +81,7 @@ interface SegmentEasingPopoverProps {
onChange: SegmentEasingChange
onDragStart?: () => void
onDragEnd?: () => void
+ onDragCancel?: () => void
}
export function SegmentEasingPopover({
@@ -96,6 +97,7 @@ export function SegmentEasingPopover({
onChange,
onDragStart,
onDragEnd,
+ onDragCancel,
}: SegmentEasingPopoverProps) {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
@@ -397,12 +399,14 @@ export function SegmentEasingPopover({
{editing ? (
({
+ keyframe: {
+ id: `kf-${i}`,
+ frame,
+ value: 30 + i * 20,
+ easing: 'cubic-bezier',
+ easingConfig: { type: 'cubic-bezier', bezier },
+ },
+ itemId: 'item',
+ property: 'x',
+ x: 100 + i * 150,
+ y: 150 - i * 50,
+ isSelected: i === 0,
+ isDragging: false,
+}))
+let rafs: Map
+let rafId = 0
+const cancel = (node: Element) =>
+ act(() => {
+ node.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+const flush = () =>
+ act(() => {
+ const queued = [...rafs.values()]
+ rafs.clear()
+ queued.forEach((fn) => fn(16))
+ })
+const pointer = { button: 0, pointerId: 7, clientX: 100, clientY: 150 }
+const moved = { ...pointer, clientX: 160, clientY: 120 }
+function transaction() {
+ let value = 100
+ let snapshot = value
+ const history: number[] = []
+ return {
+ get value() {
+ return value
+ },
+ history,
+ begin: vi.fn(() => {
+ snapshot = value
+ }),
+ change: vi.fn((next: number) => {
+ value = next
+ }),
+ end: vi.fn(() => {
+ if (snapshot !== value) history.push(snapshot)
+ }),
+ cancel: vi.fn(() => {
+ value = snapshot
+ }),
+ undo: () => {
+ value = history.pop() ?? value
+ },
+ }
+}
+
+beforeEach(() => {
+ rafs = new Map()
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ )
+ vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
+ rafs.set(++rafId, fn)
+ return rafId
+ })
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => rafs.delete(id))
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 600,
+ bottom: 300,
+ width: 600,
+ height: 300,
+ toJSON() {},
+ })
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600)
+ Object.defineProperty(Element.prototype, 'setPointerCapture', {
+ configurable: true,
+ value: vi.fn(),
+ })
+ Object.defineProperty(Element.prototype, 'releasePointerCapture', {
+ configurable: true,
+ value: vi.fn(),
+ })
+ Object.defineProperty(Element.prototype, 'hasPointerCapture', {
+ configurable: true,
+ value: () => true,
+ })
+})
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ Reflect.deleteProperty(Element.prototype, 'setPointerCapture')
+ Reflect.deleteProperty(Element.prototype, 'releasePointerCapture')
+ Reflect.deleteProperty(Element.prototype, 'hasPointerCapture')
+})
+
+describe('retained dopesheet gesture cancellation', () => {
+ it.each(['pending', 'active', 'duplicate', 'group', 'unmount'])(
+ 'retires %s diamond intent before a late release',
+ (mode) => {
+ const tx = transaction()
+ const duplicate = vi.fn()
+ const ui = render(
+
+ tx.change(frame)}
+ onDuplicateKeyframes={duplicate}
+ onDragStart={tx.begin}
+ onDragEnd={tx.end}
+ onDragCancel={tx.cancel}
+ />
+
,
+ )
+ if (mode === 'group')
+ fireEvent.click(screen.getByRole('button', { name: /collapse transform/i }))
+ const diamond =
+ mode === 'group'
+ ? ui.container.querySelector('[data-testid^="group-keyframe-"]')!
+ : screen.getByTestId('row-keyframe-x-kf-0')
+ fireEvent.pointerDown(diamond, { ...pointer, altKey: mode === 'duplicate' })
+ if (mode !== 'pending') fireEvent.pointerMove(window, moved)
+ if (mode === 'unmount') ui.unmount()
+ else cancel(screen.getByTestId('shell'))
+ fireEvent.pointerMove(window, moved)
+ fireEvent.pointerUp(window, moved)
+ fireEvent.pointerUp(window, moved)
+ flush()
+ expect(tx.change).not.toHaveBeenCalled()
+ expect(duplicate).not.toHaveBeenCalled()
+ expect(tx.end).not.toHaveBeenCalled()
+ expect(tx.history).toEqual([])
+ expect(Element.prototype.releasePointerCapture).toHaveBeenCalledWith(7)
+ },
+ )
+
+ it.each(['normal', 'foreign', 'duplicate-normal', 'delegated-cancel'])(
+ 'diamond %s keeps its commit/undo boundary',
+ (mode) => {
+ const tx = transaction()
+ const duplicate = vi.fn(() => {
+ tx.begin()
+ tx.change(200)
+ tx.end()
+ })
+ render(
+
+
+ tx.change(frame)}
+ onDuplicateKeyframes={duplicate}
+ onSelectionFrameDelta={
+ mode === 'delegated-cancel'
+ ? (delta, phase) => {
+ if (phase === 'preview') tx.change(100 + delta)
+ return true
+ }
+ : undefined
+ }
+ onDragStart={tx.begin}
+ onDragEnd={tx.end}
+ onDragCancel={tx.cancel}
+ />
+
+
,
+ )
+ fireEvent.pointerDown(screen.getByTestId('row-keyframe-x-kf-0'), {
+ ...pointer,
+ altKey: mode === 'duplicate-normal',
+ })
+ fireEvent.pointerMove(window, moved)
+ if (mode === 'foreign') cancel(screen.getByTestId('outer'))
+ if (mode === 'delegated-cancel') {
+ expect(tx.value).not.toBe(100)
+ cancel(screen.getByTestId('shell'))
+ }
+ fireEvent.pointerUp(window, moved)
+ fireEvent.pointerUp(window, moved)
+ if (mode === 'delegated-cancel') {
+ expect(tx.value).toBe(100)
+ expect(tx.history).toEqual([])
+ } else {
+ expect(tx.value).not.toBe(100)
+ expect(tx.history).toEqual([100])
+ tx.undo()
+ expect(tx.value).toBe(100)
+ }
+ },
+ )
+
+ it.each(['normal', 'foreign', 'cancel', 'pending'])(
+ 'property scrub %s preserves one transaction',
+ (mode) => {
+ const tx = transaction()
+ render(
+
+
+ tx.change(value)}
+ onPropertyValueCommit={(_property, value) => tx.change(value)}
+ onDragStart={tx.begin}
+ onDragEnd={tx.end}
+ onDragCancel={tx.cancel}
+ />
+
+
,
+ )
+ const input = screen.getByRole('spinbutton', { name: 'X Position value at playhead' })
+ fireEvent.focus(input)
+ fireEvent.pointerDown(input, pointer)
+ if (mode !== 'pending') fireEvent.pointerMove(input, moved)
+ if (mode === 'foreign') cancel(screen.getByTestId('outer'))
+ if (mode === 'cancel' || mode === 'pending') cancel(screen.getByTestId('shell'))
+ const count = tx.change.mock.calls.length
+ fireEvent.pointerUp(input, moved)
+ fireEvent.pointerUp(input, moved)
+ fireEvent.blur(input)
+ if (mode === 'cancel' || mode === 'pending') {
+ expect(tx.value).toBe(100)
+ expect(tx.history).toEqual([])
+ expect(tx.change).toHaveBeenCalledTimes(count)
+ } else {
+ expect(tx.value).not.toBe(100)
+ expect(tx.history).toEqual([100])
+ tx.undo()
+ expect(tx.value).toBe(100)
+ }
+ },
+ )
+
+ it('cancels ruler pending frame and edge RAF while preserving last delivered frame', () => {
+ const scrub = vi.fn()
+ const end = vi.fn()
+ const edge = vi.fn(() => 10)
+ render(
+
+
+
,
+ )
+ const ruler = screen.getByTestId('dopesheet-ruler')
+ fireEvent.pointerDown(ruler, pointer)
+ fireEvent.pointerMove(ruler, { ...moved, clientX: 900 })
+ const calls = scrub.mock.calls.length
+ cancel(screen.getByTestId('shell'))
+ fireEvent.pointerMove(ruler, moved)
+ fireEvent.pointerUp(ruler, moved)
+ flush()
+ flush()
+ expect(scrub).toHaveBeenCalledTimes(calls)
+ expect(edge).not.toHaveBeenCalled()
+ expect(end).toHaveBeenCalledTimes(1)
+ })
+})
+
+function Graph({ tx, duplicate }: { tx: ReturnType; duplicate: () => void }) {
+ const h = useGraphInteraction({
+ viewport,
+ padding: DEFAULT_GRAPH_PADDING,
+ points,
+ selectedKeyframeIds: new Set(['kf-0']),
+ onKeyframeMove: (_ref, frame) => tx.change(frame),
+ onDuplicateKeyframes: duplicate,
+ onBezierHandleMove: (_ref, next) => tx.change(next.x1),
+ onDragStart: tx.begin,
+ onDragEnd: tx.end,
+ onDragCancel: tx.cancel,
+ })
+ return (
+
+ h.handleKeyframePointerDown(points[0]!, e)}
+ />
+
+ h.handleBezierPointerDown(
+ { keyframeId: 'kf-0', type: 'out', x: 130, y: 140, anchorX: 100, anchorY: 150 },
+ e,
+ )
+ }
+ />
+
+ )
+}
+it.each(['pending', 'active', 'duplicate', 'bezier', 'normal-bezier', 'normal', 'foreign'])(
+ 'value graph %s has terminal cancellation and normal commit',
+ (mode) => {
+ const tx = transaction()
+ const duplicate = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.pointerDown(screen.getByTestId(mode.includes('bezier') ? 'bezier' : 'point'), {
+ ...pointer,
+ altKey: mode === 'duplicate',
+ })
+ if (mode !== 'pending') fireEvent.pointerMove(screen.getByTestId('graph'), moved)
+ if (mode === 'foreign') cancel(screen.getByTestId('outer'))
+ else if (!mode.startsWith('normal')) cancel(screen.getByTestId('shell'))
+ fireEvent.pointerUp(screen.getByTestId('graph'), moved)
+ fireEvent.pointerUp(screen.getByTestId('graph'), moved)
+ if (mode.startsWith('normal') || mode === 'foreign') {
+ expect(tx.history).toEqual([100])
+ tx.undo()
+ expect(tx.value).toBe(100)
+ } else {
+ expect(tx.value).toBe(100)
+ expect(tx.history).toEqual([])
+ expect(duplicate).not.toHaveBeenCalled()
+ expect(tx.change).not.toHaveBeenCalled()
+ }
+ },
+)
+
+it('hidden graph playhead hit area cancels capture listeners and queued frame', () => {
+ const scrub = vi.fn()
+ const end = vi.fn()
+ const ui = render(
+
+
+
+
+
,
+ )
+ const line = ui.container.querySelector('line')!
+ const svg = ui.container.querySelector('svg')!
+ fireEvent.pointerDown(line, pointer)
+ fireEvent.pointerMove(svg, moved)
+ cancel(screen.getByTestId('shell'))
+ fireEvent.pointerMove(svg, moved)
+ fireEvent.pointerUp(svg, moved)
+ flush()
+ expect(scrub).toHaveBeenCalledTimes(1)
+ expect(scrub).toHaveBeenCalledWith(10)
+ expect(end).toHaveBeenCalledTimes(1)
+})
+
+function PortalFixture({ tx }: { tx: ReturnType }) {
+ const [currentBezier, setCurrentBezier] = useState(bezier)
+ const [ownerRef] = useState(() => createRef())
+ return (
+
+
+ Origin
+ {createPortal(
+ {
+ tx.change(next.x1)
+ setCurrentBezier(next)
+ }}
+ onChangeSpring={vi.fn()}
+ onDragStart={tx.begin}
+ onDragEnd={tx.end}
+ onDragCancel={() => {
+ tx.cancel()
+ setCurrentBezier(bezier)
+ }}
+ />,
+ document.body,
+ )}
+
+
+ )
+}
+
+it.each(['hide', 'pending-hide', 'foreign', 'normal', 'slider-hide', 'slider-normal'])(
+ 'portal easing %s retires callbacks before restoring snapshot',
+ (mode) => {
+ const tx = transaction()
+ const ui = render( )
+ const handle = mode.startsWith('slider')
+ ? document.querySelector('[aria-label="x1"]')!
+ : document.querySelector('g.cursor-grab')!
+ const pointerTarget = mode.startsWith('slider') ? handle : window
+ fireEvent.pointerDown(handle, pointer)
+ if (mode !== 'pending-hide') {
+ fireEvent.pointerMove(pointerTarget, moved)
+ expect(tx.value).not.toBe(100)
+ }
+ if (mode.endsWith('hide')) cancel(screen.getByTestId('shell'))
+ if (mode === 'foreign') cancel(screen.getByTestId('outer'))
+ fireEvent.pointerMove(pointerTarget, moved)
+ fireEvent.pointerUp(pointerTarget, moved)
+ fireEvent.pointerUp(pointerTarget, moved)
+ if (mode.endsWith('hide')) {
+ expect(tx.value).toBe(100)
+ expect(tx.history).toEqual([])
+ } else {
+ expect(tx.history).toEqual([100])
+ tx.undo()
+ expect(tx.value).toBe(100)
+ }
+ ui.unmount()
+ },
+)
+
+it('uses the current callback without cancelling on rerender, fails closed without owner, and disposes', () => {
+ const owner = document.createElement('div')
+ const shell = document.createElement('div')
+ shell.setAttribute('data-editor-workspace-shell', '')
+ shell.append(owner)
+ document.body.append(shell)
+ const first = vi.fn()
+ const latest = vi.fn()
+ const ownerRef = { current: owner as Element | null }
+ const hook = renderHook(({ callback }) => useKeyframeGestureCancellation(ownerRef, callback), {
+ initialProps: { callback: first },
+ })
+ hook.rerender({ callback: latest })
+ expect(first).not.toHaveBeenCalled()
+ expect(latest).not.toHaveBeenCalled()
+ cancel(shell)
+ expect(latest).toHaveBeenCalledWith(true)
+ ownerRef.current = null
+ cancel(shell)
+ expect(latest).toHaveBeenCalledTimes(1)
+ hook.unmount()
+ expect(latest).toHaveBeenLastCalledWith(false)
+ cancel(shell)
+ expect(latest).toHaveBeenCalledTimes(2)
+ shell.remove()
+})
diff --git a/src/features/keyframes/components/use-keyframe-gesture-cancellation.ts b/src/features/keyframes/components/use-keyframe-gesture-cancellation.ts
new file mode 100644
index 000000000..dc798b51e
--- /dev/null
+++ b/src/features/keyframes/components/use-keyframe-gesture-cancellation.ts
@@ -0,0 +1,31 @@
+import { useEffect, useRef, type RefObject } from 'react'
+
+/** Cancel retained keyframe gestures only for their nearest editor instance. */
+export function useKeyframeGestureCancellation(
+ ownerRef: RefObject,
+ cancel: (updateReactState: boolean) => void,
+) {
+ const latest = useRef({ ownerRef, cancel })
+ latest.current = { ownerRef, cancel }
+ useEffect(() => {
+ const onCancel = (event: Event) => {
+ const root = latest.current.ownerRef.current?.closest(
+ '[data-editor-workspace-shell], [data-freecut-editor-surface], [role="application"]',
+ )
+ if (root && event.target === root) latest.current.cancel(true)
+ }
+ window.addEventListener('freecut:cancel-timeline-gesture', onCancel)
+ return () => {
+ window.removeEventListener('freecut:cancel-timeline-gesture', onCancel)
+ latest.current.cancel(false)
+ }
+ }, [])
+}
+
+export function releaseKeyframePointerCapture(target: Element | null, pointerId: number) {
+ try {
+ target?.releasePointerCapture(pointerId)
+ } catch {
+ // The browser may already have released capture during hide/unmount.
+ }
+}
diff --git a/src/features/keyframes/components/value-graph-editor/graph-curve.tsx b/src/features/keyframes/components/value-graph-editor/graph-curve.tsx
index 38fe9b5ad..7d5ed5226 100644
--- a/src/features/keyframes/components/value-graph-editor/graph-curve.tsx
+++ b/src/features/keyframes/components/value-graph-editor/graph-curve.tsx
@@ -1,3 +1,7 @@
+import {
+ useKeyframeGestureCancellation,
+ releaseKeyframePointerCapture,
+} from '../use-keyframe-gesture-cancellation'
/**
* Graph curve component.
* Renders interpolation curves between keyframes on the value graph.
@@ -261,10 +265,13 @@ export const GraphPlayhead = memo(function GraphPlayhead({
// directly. On settled seek/zoom the editor re-renders and the layout effect
// below repositions from the `frame` prop.
const groupRef = useRef(null)
+ const cancelGestureRef = useRef<(() => void) | null>(null)
+ useKeyframeGestureCancellation(groupRef, () => cancelGestureRef.current?.())
const {
startScrub: startPlayheadScrub,
queueScrub: queuePlayheadScrub,
flushPendingScrub: flushPendingPlayheadScrub,
+ cancelPendingScrub: cancelPendingPlayheadScrub,
} = useCoalescedScrub(onScrub)
useLayoutEffect(() => {
@@ -306,14 +313,17 @@ export const GraphPlayhead = memo(function GraphPlayhead({
const svg = (event.target as SVGElement).ownerSVGElement
if (!svg) return
+ cancelGestureRef.current?.()
// Notify scrub start
onScrubStart?.()
// Capture pointer for drag
svg.setPointerCapture(event.pointerId)
let lastScrubbedFrame: number | null = null
+ let active = true
const handlePointerMove = (e: PointerEvent) => {
+ if (!active || e.pointerId !== event.pointerId) return
e.preventDefault()
e.stopPropagation()
const rect = svg.getBoundingClientRect()
@@ -332,18 +342,25 @@ export const GraphPlayhead = memo(function GraphPlayhead({
// Also handles pointercancel — a system-interrupted gesture (capture lost,
// touch cancelled) must clean up exactly like a normal release.
- const handlePointerUp = (e: PointerEvent) => {
- e.preventDefault()
- e.stopPropagation()
- svg.releasePointerCapture(event.pointerId)
+ const finish = (commit: boolean) => {
+ if (!active) return
+ active = false
+ cancelGestureRef.current = null
+ releaseKeyframePointerCapture(svg, event.pointerId)
svg.removeEventListener('pointermove', handlePointerMove)
svg.removeEventListener('pointerup', handlePointerUp)
svg.removeEventListener('pointercancel', handlePointerUp)
- flushPendingPlayheadScrub(true)
-
- // Notify scrub end
+ if (commit) flushPendingPlayheadScrub(true)
+ else cancelPendingPlayheadScrub()
onScrubEnd?.()
}
+ const handlePointerUp = (e: PointerEvent) => {
+ if (!active || e.pointerId !== event.pointerId) return
+ e.preventDefault()
+ e.stopPropagation()
+ finish(true)
+ }
+ cancelGestureRef.current = () => finish(false)
svg.addEventListener('pointermove', handlePointerMove)
svg.addEventListener('pointerup', handlePointerUp)
diff --git a/src/features/keyframes/components/value-graph-editor/graph-interaction-types.ts b/src/features/keyframes/components/value-graph-editor/graph-interaction-types.ts
index bf2fa3b21..7f79daa37 100644
--- a/src/features/keyframes/components/value-graph-editor/graph-interaction-types.ts
+++ b/src/features/keyframes/components/value-graph-editor/graph-interaction-types.ts
@@ -124,6 +124,7 @@ export interface UseGraphInteractionOptions {
onDragStart?: () => void
/** Callback when drag ends (for undo batching) */
onDragEnd?: () => void
+ onDragCancel?: () => void
/** Whether snapping is enabled */
snapEnabled?: boolean
/** Snap targets for frames (other keyframe frames, playhead, etc.) */
diff --git a/src/features/keyframes/components/value-graph-editor/index.tsx b/src/features/keyframes/components/value-graph-editor/index.tsx
index f5dc99eef..a85ce4a37 100644
--- a/src/features/keyframes/components/value-graph-editor/index.tsx
+++ b/src/features/keyframes/components/value-graph-editor/index.tsx
@@ -106,6 +106,7 @@ interface ValueGraphEditorSharedProps {
onDragStart?: () => void
/** Callback when drag ends (for undo batching) */
onDragEnd?: () => void
+ onDragCancel?: () => void
/** Callback to add a keyframe at the current frame */
onAddKeyframe?: (property: AnimatableProperty, frame: number) => void
/** Callback to remove selected keyframes */
@@ -183,6 +184,7 @@ const ValueGraphEditorBase = memo(function ValueGraphEditorBase({
onScrubEnd,
onDragStart,
onDragEnd,
+ onDragCancel,
onAddKeyframe,
onRemoveKeyframes,
onNavigateToKeyframe,
@@ -430,8 +432,7 @@ const ValueGraphEditorBase = memo(function ValueGraphEditorBase({
}> => {
if (
!proceduralPreview ||
- (proceduralPreview.modifiers.length === 0 &&
- (proceduralPreview.layers?.length ?? 0) === 0)
+ (proceduralPreview.modifiers.length === 0 && (proceduralPreview.layers?.length ?? 0) === 0)
)
return []
const { graphLeft, graphTop, graphWidth, graphHeight, frameRange } = getGraphDimensions(
@@ -578,6 +579,7 @@ const ValueGraphEditorBase = memo(function ValueGraphEditorBase({
onBezierHandleMove,
onDragStart,
onDragEnd,
+ onDragCancel,
snapEnabled,
snapFrameTargets: snapTargets.frameTargets,
snapValueTargets: snapTargets.valueTargets,
diff --git a/src/features/keyframes/components/value-graph-editor/use-graph-interaction.ts b/src/features/keyframes/components/value-graph-editor/use-graph-interaction.ts
index 81b1d3e5c..48515072d 100644
--- a/src/features/keyframes/components/value-graph-editor/use-graph-interaction.ts
+++ b/src/features/keyframes/components/value-graph-editor/use-graph-interaction.ts
@@ -1,3 +1,7 @@
+import {
+ useKeyframeGestureCancellation,
+ releaseKeyframePointerCapture,
+} from '../use-keyframe-gesture-cancellation'
/**
* Graph interaction hook.
* Handles pointer events, dragging, zoom, and pan for the value graph editor.
@@ -44,6 +48,7 @@ export function useGraphInteraction({
onBezierHandleMove,
onDragStart,
onDragEnd,
+ onDragCancel,
snapEnabled = false,
snapFrameTargets = [],
snapValueTargets = [],
@@ -93,6 +98,7 @@ export function useGraphInteraction({
onBackgroundClick,
onDragStart,
onDragEnd,
+ onDragCancel,
})
useEffect(() => {
callbacksRef.current = {
@@ -104,6 +110,7 @@ export function useGraphInteraction({
onBackgroundClick,
onDragStart,
onDragEnd,
+ onDragCancel,
}
}, [
onKeyframeMove,
@@ -114,6 +121,7 @@ export function useGraphInteraction({
onBackgroundClick,
onDragStart,
onDragEnd,
+ onDragCancel,
])
// Track whether we've called onDragStart for the current drag operation
@@ -734,6 +742,28 @@ export function useGraphInteraction({
[dragStateType, marqueeStateRef],
)
+ useKeyframeGestureCancellation(svgRef, (updateReactState) => {
+ const pointerId = dragStartRef.current?.pointerId ?? bezierDragStartRef.current?.pointerId
+ const target = svgRef.current
+ const started = dragStartCalledRef.current
+ dragStartRef.current = null
+ bezierDragStartRef.current = null
+ previewValuesRef.current = null
+ previewBezierConfigsRef.current = null
+ dragStartCalledRef.current = false
+ svgRef.current = null
+ if (pointerId !== undefined) releaseKeyframePointerCapture(target, pointerId)
+ if (updateReactState) {
+ setDragState(null)
+ setIsDragging(false)
+ setPreviewValues(null)
+ setPreviewBezierConfigs(null)
+ setDraggingHandle(null)
+ setConstraintAxis(null)
+ }
+ if (started) callbacksRef.current.onDragCancel?.()
+ })
+
const { handleWheel } = useGraphWheel({
disabled,
viewport,
diff --git a/src/features/preview/components/source-monitor.test.tsx b/src/features/preview/components/source-monitor.test.tsx
index 5f3719139..01460c1b8 100644
--- a/src/features/preview/components/source-monitor.test.tsx
+++ b/src/features/preview/components/source-monitor.test.tsx
@@ -67,6 +67,8 @@ const playerMethodsState = vi.hoisted(() => ({
toggle: vi.fn(),
frameBack: vi.fn(),
frameForward: vi.fn(),
+ isPlaying: vi.fn(() => false),
+ setPlaybackRate: vi.fn(),
}))
const clockState = vi.hoisted(() => ({
@@ -259,6 +261,18 @@ describe('SourceMonitor current media ownership', () => {
beforeEach(() => {
vi.clearAllMocks()
+ sourcePlayerStoreState.previewSourceFrame = null
+ sourcePlayerStoreState.inPoint = null
+ sourcePlayerStoreState.outPoint = null
+ sourcePlayerStoreState.setPreviewSourceFrame.mockImplementation((frame: number | null) => {
+ sourcePlayerStoreState.previewSourceFrame = frame
+ })
+ sourcePlayerStoreState.setInPoint.mockImplementation((frame: number | null) => {
+ sourcePlayerStoreState.inPoint = frame
+ })
+ sourcePlayerStoreState.setOutPoint.mockImplementation((frame: number | null) => {
+ sourcePlayerStoreState.outPoint = frame
+ })
sourceBindingState.globalVersion = 0
sourceBindingState.epochs.clear()
sourceBindingState.resolveMediaUrl.mockResolvedValue('blob:media-1')
@@ -424,6 +438,199 @@ describe('SourceMonitor current media ownership', () => {
expect(playerMethodsState.seek).toHaveBeenCalledWith(112)
})
+ it('freezes the last visible source scrub on owner hide before late movement and release', async () => {
+ const rendered = render(
+
+
+
,
+ )
+ const bar = await rendered.findByTestId('source-monitor-seek-bar')
+ const owner = rendered.getByTestId('owner')
+ const bounds = vi.spyOn(bar, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 10,
+ width: 100,
+ height: 10,
+ toJSON: () => ({}),
+ })
+ fireEvent.mouseDown(bar, { clientX: 25 })
+ expect(sourcePlayerStoreState.setPreviewSourceFrame).toHaveBeenLastCalledWith(37)
+ // Queue a new pointer position without letting its RAF become visible.
+ fireEvent.mouseMove(document, { clientX: 75 })
+ act(() => {
+ owner.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ owner.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+ owner.style.display = 'none'
+ bounds.mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: 0,
+ height: 0,
+ toJSON: () => ({}),
+ })
+ fireEvent.mouseMove(document, { clientX: 90 })
+ fireEvent.mouseUp(document)
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ })
+ expect(sourcePlayerStoreState.setPreviewSourceFrame).toHaveBeenLastCalledWith(37)
+ expect(playerMethodsState.seek).not.toHaveBeenCalled()
+ expect(playerMethodsState.play).not.toHaveBeenCalled()
+ expect(sourceBindingState.compositionUnmounts).toBe(0)
+ owner.style.display = ''
+ fireEvent.click(rendered.getByRole('button', { name: 'Play (Space)' }))
+ expect(playerMethodsState.seek).toHaveBeenLastCalledWith(37)
+ expect(playerMethodsState.play).toHaveBeenCalledTimes(1)
+ expect(sourcePlayerStoreState.previewSourceFrame).toBeNull()
+ })
+
+ it.each(['in-handle', 'out-handle', 'range'])(
+ 'cancels owned I/O %s capture and retains applied range/preview',
+ async (kind) => {
+ sourcePlayerStoreState.inPoint = 30
+ sourcePlayerStoreState.outPoint = 120
+ const rendered = render(
+
+
+
,
+ )
+ const control = await rendered.findByTestId(`source-monitor-io-${kind}`)
+ const owner = rendered.getByTestId('owner')
+ const strip =
+ kind === 'range' ? control.parentElement! : control.parentElement!.parentElement!
+ const hitTarget = kind === 'range' ? control : control.nextElementSibling!
+ const bounds = vi.spyOn(strip, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 10,
+ width: 100,
+ height: 10,
+ toJSON: () => ({}),
+ })
+ fireEvent.pointerDown(hitTarget, { button: 0, pointerId: 1, clientX: 40 })
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 50 })
+ const held = sourcePlayerStoreState.previewSourceFrame
+ expect(held).toBeGreaterThan(0)
+ const applied = [sourcePlayerStoreState.inPoint, sourcePlayerStoreState.outPoint]
+ const foreign = document.createElement('div')
+ document.body.append(foreign)
+ act(() =>
+ foreign.dispatchEvent(
+ new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }),
+ ),
+ )
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 55 })
+ expect(sourcePlayerStoreState.previewSourceFrame).not.toBe(held)
+ const finalPreview = sourcePlayerStoreState.previewSourceFrame
+ const finalRange = [sourcePlayerStoreState.inPoint, sourcePlayerStoreState.outPoint]
+ expect(finalRange).not.toEqual(applied)
+ act(() => {
+ owner.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ owner.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+ owner.style.display = 'none'
+ bounds.mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: 0,
+ height: 0,
+ toJSON: () => ({}),
+ })
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 0 })
+ fireEvent.pointerUp(document, { pointerId: 1 })
+ expect([sourcePlayerStoreState.inPoint, sourcePlayerStoreState.outPoint]).toEqual(finalRange)
+ expect(sourcePlayerStoreState.previewSourceFrame).toBe(finalPreview)
+ expect(playerMethodsState.seek).not.toHaveBeenCalled()
+ expect(playerMethodsState.play).not.toHaveBeenCalled()
+ owner.style.display = ''
+ fireEvent.click(rendered.getByRole('button', { name: 'Play (Space)' }))
+ expect(playerMethodsState.seek).toHaveBeenLastCalledWith(finalPreview)
+ expect(playerMethodsState.play).toHaveBeenCalledTimes(1)
+ expect(sourcePlayerStoreState.previewSourceFrame).toBeNull()
+ foreign.remove()
+ },
+ )
+
+ it('ignores cancellation from another shell while a normal scrub commits on release', async () => {
+ const rendered = render(
+
+
+
,
+ )
+ const bar = await rendered.findByTestId('source-monitor-seek-bar')
+ vi.spyOn(bar, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 10,
+ width: 100,
+ height: 10,
+ toJSON: () => ({}),
+ })
+ const foreign = document.createElement('div')
+ foreign.setAttribute('data-editor-workspace-shell', '')
+ document.body.append(foreign)
+ fireEvent.mouseDown(bar, { clientX: 25 })
+ act(() =>
+ foreign.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true })),
+ )
+ fireEvent.mouseMove(document, { clientX: 75 })
+ fireEvent.mouseUp(document)
+ expect(playerMethodsState.seek).toHaveBeenCalledExactlyOnceWith(112)
+ expect(sourcePlayerStoreState.previewSourceFrame).toBeNull()
+ foreign.remove()
+ })
+
+ it.each(['in-handle', 'out-handle', 'range'])(
+ 'normal I/O %s release retains range and ends preview',
+ async (kind) => {
+ sourcePlayerStoreState.inPoint = 30
+ sourcePlayerStoreState.outPoint = 120
+ const rendered = render( )
+ const control = await rendered.findByTestId(`source-monitor-io-${kind}`)
+ const strip =
+ kind === 'range' ? control.parentElement! : control.parentElement!.parentElement!
+ const target = kind === 'range' ? control : control.nextElementSibling!
+ vi.spyOn(strip, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 10,
+ width: 100,
+ height: 10,
+ toJSON: () => ({}),
+ })
+ fireEvent.pointerDown(target, { button: 0, pointerId: 1, clientX: 40 })
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 50 })
+ const applied = [sourcePlayerStoreState.inPoint, sourcePlayerStoreState.outPoint]
+ expect(applied).not.toEqual([30, 120])
+ fireEvent.pointerUp(document, { pointerId: 1 })
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 0 })
+ expect([sourcePlayerStoreState.inPoint, sourcePlayerStoreState.outPoint]).toEqual(applied)
+ expect(sourcePlayerStoreState.previewSourceFrame).toBeNull()
+ },
+ )
+
it('pauses playback when seek-bar scrubbing starts', async () => {
clockState.isPlaying = true
const rendered = render( )
diff --git a/src/features/preview/components/source-monitor.tsx b/src/features/preview/components/source-monitor.tsx
index 578c5ae7c..3337dc385 100644
--- a/src/features/preview/components/source-monitor.tsx
+++ b/src/features/preview/components/source-monitor.tsx
@@ -784,7 +784,7 @@ function SourcePlaybackControls({
if (!bar) return null
const rect = bar.getBoundingClientRect()
if (rect.width <= 0) {
- return 0
+ return null
}
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
return Math.round(pct * lastFrame)
@@ -915,26 +915,23 @@ function SourcePlaybackControls({
[flushBarSeekFrame, frameFromBarX, player, playing, scheduleBarSeekFrame],
)
- // Clean up document listeners on unmount
- useEffect(() => {
- return () => {
- pendingBarSeekFrameRef.current = null
- pendingBarPointerXRef.current = null
- lastIssuedBarSeekFrameRef.current = null
- if (barSeekRafRef.current !== null) {
- cancelAnimationFrame(barSeekRafRef.current)
- barSeekRafRef.current = null
- }
- if (onMoveRef.current) {
- document.removeEventListener('mousemove', onMoveRef.current)
- onMoveRef.current = null
- }
- if (onUpRef.current) {
- document.removeEventListener('mouseup', onUpRef.current)
- onUpRef.current = null
- }
- draggingRef.current = false
+ const cancelBarScrub = useCallback(() => {
+ pendingBarSeekFrameRef.current = null
+ pendingBarPointerXRef.current = null
+ lastIssuedBarSeekFrameRef.current = null
+ if (barSeekRafRef.current !== null) {
+ cancelAnimationFrame(barSeekRafRef.current)
+ barSeekRafRef.current = null
+ }
+ if (onMoveRef.current) {
+ document.removeEventListener('mousemove', onMoveRef.current)
+ onMoveRef.current = null
}
+ if (onUpRef.current) {
+ document.removeEventListener('mouseup', onUpRef.current)
+ onUpRef.current = null
+ }
+ draggingRef.current = false
}, [])
// I/O marker positions as percentages
@@ -1027,8 +1024,27 @@ function SourcePlaybackControls({
)
useEffect(() => {
- return () => ioDragCleanupRef.current?.()
- }, [])
+ const cancel = (event: Event) => {
+ const root = barRef.current?.closest(
+ '[data-editor-workspace-shell], [data-freecut-editor-surface], [role="application"]',
+ )
+ if (!root || event.target !== root) return
+ cancelBarScrub()
+ // I/O updates are already applied. End capture/readout, retaining the
+ // last visible preview for the existing explicit play/seek path.
+ if (ioDragCleanupRef.current) {
+ const previewFrame = useSourcePlayerStore.getState().previewSourceFrame
+ ioDragCleanupRef.current()
+ useSourcePlayerStore.getState().setPreviewSourceFrame(previewFrame)
+ }
+ }
+ window.addEventListener('freecut:cancel-timeline-gesture', cancel)
+ return () => {
+ window.removeEventListener('freecut:cancel-timeline-gesture', cancel)
+ cancelBarScrub()
+ ioDragCleanupRef.current?.()
+ }
+ }, [cancelBarScrub])
// Duration display when both I/O are set
const ioDuration =
@@ -1250,10 +1266,12 @@ function SourcePlaybackControls({
left={`${inPct}%`}
width={`${outPct - inPct}%`}
height={SOURCE_IO_LANE_HEIGHT}
+ testId="source-monitor-io-range"
onDragStart={handleIORangeDragStart}
/>
)}
s.items[0]!)
+ const owner = useRef(null)
+ const active = useRef(false)
+ const edit = useFadeEditors({
+ item,
+ fps: 30,
+ activeTool: 'select',
+ trackLocked: locked,
+ isAnyDragActiveRef: active,
+ transformRef: owner,
+ updateTimelineItem: useTimelineStore.getState().updateItem,
+ })
+ const handle = mode.endsWith('out') ? 'out' : 'in'
+ return (
+
+
+
+
+
+
+
{
+ if (mode.startsWith('video')) edit.handleVideoFadeHandleMouseDown(e, handle)
+ else if (mode.startsWith('audio')) edit.handleAudioFadeHandleMouseDown(e, handle)
+ else if (mode.startsWith('curve')) edit.handleAudioFadeCurveDotMouseDown(e, handle)
+ else edit.handleAudioVolumeMouseDown(e)
+ }}
+ >
+ Gesture
+
+
+ {JSON.stringify({
+ video: edit.videoFadeEdit,
+ audio: edit.audioFadeEdit,
+ curve: edit.audioFadeCurveEdit,
+ volume: edit.audioVolumeEdit,
+ })}
+
+
+
+
+
+ )
+}
+function mount(mode: Mode) {
+ const base = {
+ id: 'clip',
+ from: 0,
+ durationInFrames: 60,
+ fadeIn: 0.2,
+ fadeOut: 0.2,
+ audioFadeIn: 0.4,
+ audioFadeOut: 0.4,
+ volume: 0,
+ }
+ useItemsStore
+ .getState()
+ .setItems([
+ mode.startsWith('video') ? makeTimelineVideoItem(base) : makeTimelineAudioItem(base),
+ ])
+ const view = render( )
+ for (const el of view.container.querySelectorAll(
+ '[data-geometry],[data-testid="owner"]',
+ ))
+ el.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 200,
+ bottom: 100,
+ width: 200,
+ height: 100,
+ toJSON: () => ({}),
+ })
+ return view
+}
+function press(view: ReturnType, mode: Mode) {
+ fireEvent.mouseDown(view.getByRole('button'), {
+ button: 0,
+ clientX: mode.endsWith('out') ? 180 : 20,
+ clientY: 50,
+ })
+}
+function move(mode: Mode) {
+ fireEvent.mouseMove(window, { clientX: mode.endsWith('out') ? 150 : 60, clientY: 20 })
+}
+function cancel(el: Element) {
+ act(() => {
+ el.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+}
+function snapshot() {
+ return structuredClone(useItemsStore.getState().items)
+}
+function idle(view: ReturnType) {
+ expect(view.container.querySelector('output')?.textContent).toBe(
+ '{"video":null,"audio":null,"curve":null,"volume":null}',
+ )
+}
+beforeEach(() => {
+ resetTimelineCompositionTestState()
+ clearMixerLiveGains()
+ useItemsStore
+ .getState()
+ .setTracks([
+ makeTimelineTrack({ id: 'track-v1', name: 'Video', order: 0 }),
+ makeTimelineTrack({ id: 'track-a1', name: 'Audio', order: 1 }),
+ ])
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ )
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ clearMixerLiveGains()
+})
+describe('retained fade and volume lifecycle', () => {
+ it.each(modes)('%s cancels changed preview, duplicate cancellation and late mouseup', (mode) => {
+ const view = mount(mode),
+ before = snapshot()
+ setMixerLiveGains([{ itemId: 'clip', gain: 1.7 }])
+ setMixerLiveGainLayer('other', [{ itemId: 'clip', gain: 0.5 }])
+ const gain = getMixerLiveGain('clip')
+ press(view, mode)
+ move(mode)
+ expect(view.container.querySelector('output')?.textContent).not.toBe(
+ '{"video":null,"audio":null,"curve":null,"volume":null}',
+ )
+ if (mode === 'volume') expect(getMixerLiveGain('clip')).not.toBe(gain)
+ cancel(view.getByTestId('root'))
+ cancel(view.getByTestId('root'))
+ move(mode)
+ fireEvent.mouseUp(window)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ idle(view)
+ expect(getMixerLiveGain('clip')).toBeCloseTo(gain)
+ })
+ it.each(modes)('%s foreign root and rerender preserve one normal commit and undo', (mode) => {
+ const view = mount(mode),
+ before = snapshot()
+ press(view, mode)
+ move(mode)
+ view.rerender( )
+ cancel(view.getByTestId('foreign'))
+ cancel(view.getByTestId('outer'))
+ fireEvent.mouseUp(window)
+ fireEvent.mouseUp(window)
+ expect(snapshot()).not.toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1)
+ act(() => useTimelineCommandStore.getState().undo())
+ expect(snapshot()).toEqual(before)
+ })
+ it.each(modes)('%s actual unmount discards preview and leaves no late commit', (mode) => {
+ const view = mount(mode),
+ before = snapshot()
+ press(view, mode)
+ move(mode)
+ view.unmount()
+ move(mode)
+ fireEvent.mouseUp(window)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ expect(getMixerLiveGain('clip')).toBe(1)
+ })
+ it('cancels pending volume timeout before activation or late movement', () => {
+ vi.useFakeTimers()
+ const view = mount('volume'),
+ before = snapshot()
+ press(view, 'volume')
+ cancel(view.getByTestId('root'))
+ act(() => vi.advanceTimersByTime(500))
+ move('volume')
+ fireEvent.mouseUp(window)
+ idle(view)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ expect(getMixerLiveGain('clip')).toBe(1)
+ })
+ it.each([0.5, 0])(
+ 'restores the owned default gain with an initial foreign layer of %s',
+ (foreignGain) => {
+ const view = mount('volume')
+ const before = snapshot()
+ setMixerLiveGains([{ itemId: 'clip', gain: 1.7 }])
+ setMixerLiveGainLayer('other', [{ itemId: 'clip', gain: foreignGain }])
+ press(view, 'volume')
+ move('volume')
+ if (foreignGain !== 0) setMixerLiveGainLayer('other', [{ itemId: 'clip', gain: 0.8 }])
+ cancel(view.getByTestId('root'))
+ expect(getMixerLiveGain('clip')).toBeCloseTo(1.7 * (foreignGain === 0 ? 0 : 0.8))
+ clearMixerLiveGainLayer('other')
+ expect(getMixerLiveGain('clip')).toBeCloseTo(1.7)
+ fireEvent.mouseUp(window)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ },
+ )
+})
diff --git a/src/features/timeline/components/timeline-item/use-fade-editors.ts b/src/features/timeline/components/timeline-item/use-fade-editors.ts
index 86e8daf85..2e76ef80f 100644
--- a/src/features/timeline/components/timeline-item/use-fade-editors.ts
+++ b/src/features/timeline/components/timeline-item/use-fade-editors.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from '../../hooks/use-timeline-gesture-cancellation'
import {
useCallback,
useEffect,
@@ -11,6 +12,7 @@ import type { TimelineItem as TimelineItemType } from '@/types/timeline'
import {
clearMixerLiveGain,
getMixerLiveGain,
+ getDefaultMixerLiveGain,
setMixerLiveGains,
} from '@/shared/state/mixer-live-gain'
import {
@@ -140,6 +142,7 @@ export function useFadeEditors({
const audioFadeCurveCleanupRef = useRef<(() => void) | null>(null)
const [audioVolumeEdit, setAudioVolumeEdit] = useState(null)
+ const rollbackAudioVolumeRef = useRef<(() => void) | null>(null)
const audioVolumeCleanupRef = useRef<(() => void) | null>(null)
const audioVolumePreviewRef = useRef(item.type === 'audio' ? (item.volume ?? 0) : 0)
const audioVolumeEditLabelRef = useRef(null)
@@ -148,16 +151,6 @@ export function useFadeEditors({
const audioControlsRef = useRef(null)
const volumeLineRef = useRef(null)
- useEffect(
- () => () => {
- videoFadeCleanupRef.current?.()
- audioFadeCleanupRef.current?.()
- audioFadeCurveCleanupRef.current?.()
- audioVolumeCleanupRef.current?.()
- },
- [],
- )
-
const displayedVideoFadeIn = isVisualFadeItem
? (videoFadeEdit?.previewFadeIn ?? item.fadeIn ?? 0)
: 0
@@ -231,6 +224,40 @@ export function useFadeEditors({
[snapVolumeLineTop, transformRef],
)
+ useTimelineGestureCancellation(transformRef, (updateReactState) => {
+ const videoCleanup = videoFadeCleanupRef.current
+ const audioCleanup = audioFadeCleanupRef.current
+ const curveCleanup = audioFadeCurveCleanupRef.current
+ const volumeCleanup = audioVolumeCleanupRef.current
+ // Consume owners before removing listeners or notifying preview subscribers.
+ videoFadeCleanupRef.current = null
+ audioFadeCleanupRef.current = null
+ audioFadeCurveCleanupRef.current = null
+ audioVolumeCleanupRef.current = null
+ videoCleanup?.()
+ audioCleanup?.()
+ curveCleanup?.()
+ volumeCleanup?.()
+ if (videoCleanup) {
+ videoFadeEditRef.current = null
+ if (updateReactState) setVideoFadeEdit(null)
+ }
+ if (audioCleanup) {
+ audioFadeEditRef.current = null
+ if (updateReactState) setAudioFadeEdit(null)
+ }
+ if (curveCleanup) {
+ audioFadeCurveEditRef.current = null
+ if (updateReactState) setAudioFadeCurveEdit(null)
+ }
+ if (volumeCleanup) {
+ const rollback = rollbackAudioVolumeRef.current
+ rollbackAudioVolumeRef.current = null
+ rollback?.()
+ if (updateReactState) setAudioVolumeEdit(null)
+ }
+ })
+
const itemType = item.type
const itemVolume = item.volume
useEffect(() => {
@@ -370,6 +397,7 @@ export function useFadeEditors({
e.preventDefault()
e.stopPropagation()
+ let gestureLive = true
const originalFadeIn = displayedVideoFadeIn
const originalFadeOut = displayedVideoFadeOut
@@ -403,6 +431,7 @@ export function useFadeEditors({
}
const finishEdit = () => {
+ if (!gestureLive) return
const latestState = videoFadeEditRef.current
const committedFade =
handle === 'in'
@@ -429,15 +458,18 @@ export function useFadeEditors({
applyPreview(computeFadeSeconds(e.clientX))
const handleWindowMouseMove = (event: MouseEvent) => {
+ if (!gestureLive) return
applyPreview(computeFadeSeconds(event.clientX))
}
const handleWindowMouseUp = () => {
+ if (!gestureLive) return
finishEdit()
}
window.addEventListener('mousemove', handleWindowMouseMove)
window.addEventListener('mouseup', handleWindowMouseUp, { once: true })
videoFadeCleanupRef.current = () => {
+ gestureLive = false
window.removeEventListener('mousemove', handleWindowMouseMove)
window.removeEventListener('mouseup', handleWindowMouseUp)
}
@@ -469,6 +501,7 @@ export function useFadeEditors({
e.preventDefault()
e.stopPropagation()
+ let gestureLive = true
const originalFadeIn = displayedAudioFadeIn
const originalFadeOut = displayedAudioFadeOut
@@ -502,6 +535,7 @@ export function useFadeEditors({
}
const finishEdit = () => {
+ if (!gestureLive) return
const latestState = audioFadeEditRef.current
const committedFade =
handle === 'in'
@@ -528,15 +562,18 @@ export function useFadeEditors({
applyPreview(computeFadeSeconds(e.clientX))
const handleWindowMouseMove = (event: MouseEvent) => {
+ if (!gestureLive) return
applyPreview(computeFadeSeconds(event.clientX))
}
const handleWindowMouseUp = () => {
+ if (!gestureLive) return
finishEdit()
}
window.addEventListener('mousemove', handleWindowMouseMove)
window.addEventListener('mouseup', handleWindowMouseUp, { once: true })
audioFadeCleanupRef.current = () => {
+ gestureLive = false
window.removeEventListener('mousemove', handleWindowMouseMove)
window.removeEventListener('mouseup', handleWindowMouseUp)
}
@@ -579,6 +616,7 @@ export function useFadeEditors({
e.preventDefault()
e.stopPropagation()
+ let gestureLive = true
const originalFadeInCurve = displayedAudioFadeInCurve
const originalFadeOutCurve = displayedAudioFadeOutCurve
@@ -623,6 +661,7 @@ export function useFadeEditors({
}
const finishEdit = () => {
+ if (!gestureLive) return
const latestState = audioFadeCurveEditRef.current
const committedCurve =
handle === 'in'
@@ -665,15 +704,18 @@ export function useFadeEditors({
applyPreview(computeCurve(e.clientX, e.clientY))
const handleWindowMouseMove = (event: MouseEvent) => {
+ if (!gestureLive) return
applyPreview(computeCurve(event.clientX, event.clientY))
}
const handleWindowMouseUp = () => {
+ if (!gestureLive) return
finishEdit()
}
window.addEventListener('mousemove', handleWindowMouseMove)
window.addEventListener('mouseup', handleWindowMouseUp, { once: true })
audioFadeCurveCleanupRef.current = () => {
+ gestureLive = false
window.removeEventListener('mousemove', handleWindowMouseMove)
window.removeEventListener('mouseup', handleWindowMouseUp)
}
@@ -707,9 +749,16 @@ export function useFadeEditors({
e.preventDefault()
e.stopPropagation()
+ let gestureLive = true
const originalVolume = item.volume ?? 0
const dragStartLiveGain = getMixerLiveGain(item.id)
+ const originalDefaultGain = getDefaultMixerLiveGain(item.id)
+ rollbackAudioVolumeRef.current = () => {
+ applyAudioVolumeVisualPreview(originalVolume)
+ // Restore only this gesture's layer; other layers may have changed.
+ setMixerLiveGains([{ itemId: item.id, gain: originalDefaultGain }])
+ }
const startClientY = e.clientY
let latestClientY = startClientY
let latestPreviewVolume = originalVolume
@@ -745,7 +794,7 @@ export function useFadeEditors({
}
const activateDrag = () => {
- if (isDragActive) {
+ if (!gestureLive || isDragActive) {
return
}
isDragActive = true
@@ -757,6 +806,7 @@ export function useFadeEditors({
}
const finishEdit = () => {
+ if (!gestureLive) return
const committedVolume = audioVolumePreviewRef.current ?? latestPreviewVolume
audioVolumeCleanupRef.current?.()
audioVolumeCleanupRef.current = null
@@ -767,6 +817,7 @@ export function useFadeEditors({
}
const handleWindowMouseMove = (event: MouseEvent) => {
+ if (!gestureLive) return
latestClientY = event.clientY
if (!isDragActive) {
if (Math.abs(event.clientY - startClientY) < AUDIO_VOLUME_DRAG_ACTIVATION_DISTANCE_PX) {
@@ -779,6 +830,7 @@ export function useFadeEditors({
applyPreview(computeVolumeDb(event.clientY))
}
const handleWindowMouseUp = () => {
+ if (!gestureLive) return
if (!isDragActive) {
audioVolumeCleanupRef.current?.()
audioVolumeCleanupRef.current = null
@@ -795,6 +847,7 @@ export function useFadeEditors({
activateDrag()
}, AUDIO_VOLUME_DRAG_ACTIVATION_DELAY_MS)
audioVolumeCleanupRef.current = () => {
+ gestureLive = false
clearActivationTimeout()
window.removeEventListener('mousemove', handleWindowMouseMove)
window.removeEventListener('mouseup', handleWindowMouseUp)
diff --git a/src/features/timeline/hooks/use-rate-stretch.ts b/src/features/timeline/hooks/use-rate-stretch.ts
index b68b4536c..4e24a40bb 100644
--- a/src/features/timeline/hooks/use-rate-stretch.ts
+++ b/src/features/timeline/hooks/use-rate-stretch.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
import { useState, useCallback, useRef, useEffect, useEffectEvent } from 'react'
import type { TimelineItem } from '@/types/timeline'
import { useEditorStore } from '@/shared/state/editor'
@@ -262,6 +263,7 @@ export function useRateStretch(
item: TimelineItem,
timelineDuration: number,
trackLocked: boolean = false,
+ ownerRef?: React.RefObject,
) {
const pixelsToTime = pixelsToTimeNow
const fps = useTimelineStore((s) => s.fps)
@@ -281,7 +283,7 @@ export function useRateStretch(
item.id,
)
- const [stretchState, setStretchState] = useState({
+ const [stretchState, setReactState] = useState({
isStretching: false,
handle: null,
startX: 0,
@@ -298,6 +300,11 @@ export function useRateStretch(
const stretchStateRef = useRef(stretchState)
stretchStateRef.current = stretchState
+ const setStretchState = useCallback((next: React.SetStateAction) => {
+ const value = typeof next === 'function' ? next(stretchStateRef.current) : next
+ stretchStateRef.current = value
+ setReactState(value)
+ }, [])
// Track previous snap target to avoid unnecessary store updates
const prevSnapTargetRef = useRef<{ frame: number; type: string } | null>(null)
@@ -515,6 +522,39 @@ export function useRateStretch(
})
})
+ const cancelGesture = useCallback(
+ (updateReactState = true) => {
+ if (!stretchStateRef.current.isStretching) return
+ stretchStateRef.current = { ...stretchStateRef.current, isStretching: false }
+ // Clear drag state (including snap indicator)
+ setActiveSnapTarget(null)
+ setDragState(null)
+ useLinkedEditPreviewStore.getState().clear()
+ prevSnapTargetRef.current = null
+ magneticSnapTargetsRef.current = []
+
+ const idle: StretchState = {
+ isStretching: false,
+ handle: null,
+ startX: 0,
+ initialFrom: 0,
+ initialDuration: 0,
+ sourceDuration: 0,
+ sourceFps: 30,
+ initialSpeed: 1,
+ currentDelta: 0,
+ isLoopingMedia: false,
+ isConstrained: false,
+ constraintLabel: null,
+ }
+ stretchStateRef.current = idle
+ if (updateReactState) setStretchState(idle)
+ },
+ [setActiveSnapTarget, setDragState, setStretchState],
+ )
+
+ useTimelineGestureCancellation(ownerRef, cancelGesture)
+
// Mouse up handler - commits changes to store (single update)
// Using useEffectEvent so changes to item.id, rateStretchItem don't re-register listeners
const onMouseUp = useEffectEvent(() => {
@@ -529,6 +569,7 @@ export function useRateStretch(
currentDelta,
isLoopingMedia,
} = stretchStateRef.current
+ cancelGesture()
let newDuration: number
let newFrom: number
@@ -580,28 +621,6 @@ export function useRateStretch(
rateStretchItem(item.id, newFrom, newDuration, newSpeed)
}
}
-
- // Clear drag state (including snap indicator)
- setActiveSnapTarget(null)
- setDragState(null)
- useLinkedEditPreviewStore.getState().clear()
- prevSnapTargetRef.current = null
- magneticSnapTargetsRef.current = []
-
- setStretchState({
- isStretching: false,
- handle: null,
- startX: 0,
- initialFrom: 0,
- initialDuration: 0,
- sourceDuration: 0,
- sourceFps: 30,
- initialSpeed: 1,
- currentDelta: 0,
- isLoopingMedia: false,
- isConstrained: false,
- constraintLabel: null,
- })
}
})
@@ -697,6 +716,7 @@ export function useRateStretch(
setActiveSnapTarget,
setDragState,
getMagneticSnapTargets,
+ setStretchState,
],
)
diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts
index fe89e27de..0da74650b 100644
--- a/src/features/timeline/hooks/use-timeline-drag.ts
+++ b/src/features/timeline/hooks/use-timeline-drag.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
import type React from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import type { TimelineItem, TimelineTrack } from '@/types/timeline'
@@ -602,6 +603,7 @@ export function useTimelineDrag(
const linkedMovePreviewSignatureRef = useRef('')
const selectionRollbackRef = useRef(null)
const gestureMovementRef = useRef(0)
+ const cancelQueuedMoveRef = useRef<(() => void) | null>(null)
const removeDragThresholdListenersRef = useRef<(() => void) | null>(null)
// Track Alt key state for duplication mode (dynamic toggle during drag)
@@ -671,6 +673,8 @@ export function useTimelineDrag(
suppressPostGestureClick?: boolean
updateReactState?: boolean
}) => {
+ dragStateRef.current = null
+ cancelQueuedMoveRef.current?.()
const removeDragThresholdListeners = removeDragThresholdListenersRef.current
removeDragThresholdListenersRef.current = null
removeDragThresholdListeners?.()
@@ -685,7 +689,6 @@ export function useTimelineDrag(
clearLinkedMovePreview()
prevSnapTargetRef.current = null
magneticSnapTargetsRef.current = []
- dragStateRef.current = null
isLinkedCohortDragRef.current = false
isAltDragRef.current = false
gestureMovementRef.current = 0
@@ -1781,6 +1784,7 @@ export function useTimelineDrag(
handleMouseUp()
}
+ cancelQueuedMoveRef.current = coalescedMouseMove.cancel
window.addEventListener('mousemove', coalescedMouseMove.queue)
window.addEventListener('mouseup', handleCoalescedMouseUp)
window.addEventListener('pointercancel', handleCancellation)
@@ -1792,6 +1796,7 @@ export function useTimelineDrag(
window.removeEventListener('pointercancel', handleCancellation)
window.removeEventListener('keydown', handleKeyDown)
coalescedMouseMove.cancel()
+ cancelQueuedMoveRef.current = null
}
}
}, [
@@ -1813,18 +1818,19 @@ export function useTimelineDrag(
setLinkedMovePreview,
])
- useEffect(
- () => () => {
- if (dragStateRef.current || selectionRollbackRef.current) {
- finishDragInteraction({
- rollbackSelection: true,
- suppressPostGestureClick: true,
- updateReactState: false,
- })
- }
- },
- [finishDragInteraction],
- )
+ useTimelineGestureCancellation(elementRef, (updateReactState) => {
+ if (
+ dragStateRef.current ||
+ selectionRollbackRef.current ||
+ removeDragThresholdListenersRef.current
+ ) {
+ finishDragInteraction({
+ rollbackSelection: true,
+ suppressPostGestureClick: true,
+ updateReactState,
+ })
+ }
+ })
return {
isDragging,
diff --git a/src/features/timeline/hooks/use-timeline-gesture-cancellation.dom.test.tsx b/src/features/timeline/hooks/use-timeline-gesture-cancellation.dom.test.tsx
new file mode 100644
index 000000000..c1ea2b325
--- /dev/null
+++ b/src/features/timeline/hooks/use-timeline-gesture-cancellation.dom.test.tsx
@@ -0,0 +1,263 @@
+import { useRef } from 'react'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import type { TimelineItem } from '@/types/timeline'
+import { useEditorStore } from '@/shared/state/editor'
+import { useSelectionStore } from '@/shared/state/selection'
+import {
+ makeTimelineTrack,
+ makeTimelineVideoItem,
+ resetTimelineCompositionTestState,
+} from '../test-helpers'
+import { useItemsStore } from '../stores/items-store'
+import { useTimelineCommandStore } from '../stores/timeline-command-store'
+import { useTimelineSettingsStore } from '../stores/timeline-settings-store'
+import { useZoomStore } from '../stores/zoom-store'
+import { useSlipEditPreviewStore } from '../stores/slip-edit-preview-store'
+import { useSlideEditPreviewStore } from '../stores/slide-edit-preview-store'
+import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store'
+import { useTimelineDrag } from './use-timeline-drag'
+import { useTimelineTrim } from './use-timeline-trim'
+import { useTimelineSlipSlide } from './use-timeline-slip-slide'
+import { useRateStretch } from './use-rate-stretch'
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
+
+type Tool = 'move' | 'trim' | 'slip' | 'slide' | 'stretch'
+let frames = new Map()
+let id = 0
+let clip: TimelineItem
+
+function Surface({ tool, pending = false }: { tool: Tool; pending?: boolean }) {
+ const ref = useRef(null)
+ const drag = useTimelineDrag(clip, 600, false, ref)
+ const trim = useTimelineTrim(clip, 600, false, ref)
+ const slip = useTimelineSlipSlide(clip, 600, false, ref)
+ const stretch = useRateStretch(clip, 600, false, ref)
+ return (
+
+
+
+
+
{
+ if (tool === 'move') drag.handleDragStart(event)
+ if (tool === 'trim') trim.handleTrimStart(event, 'end')
+ if (tool === 'slip' || tool === 'slide')
+ slip.handleSlipSlideStart(event, tool, { activateOnMoveThreshold: pending })
+ if (tool === 'stretch') stretch.handleStretchStart(event, 'end')
+ }}
+ >
+ clip
+
+
+
+
+
+
+ )
+}
+
+function mount(tool: Tool, pending = false) {
+ if (tool === 'move') useItemsStore.getState().setItems([clip])
+ const view = render( )
+ for (const el of view.container.querySelectorAll(
+ '.timeline-container,.timeline-tracks,[data-track-id]',
+ )) {
+ el.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ top: 0,
+ bottom: 80,
+ left: 0,
+ right: 1000,
+ width: 1000,
+ height: 80,
+ toJSON: () => ({}),
+ })
+ }
+ return view
+}
+function move(x: number) {
+ fireEvent.mouseMove(window, { clientX: x, clientY: 40 })
+ act(() => {
+ const queued = [...frames.values()]
+ frames.clear()
+ queued.forEach((cb) => cb(performance.now()))
+ })
+}
+function cancel(root: Element) {
+ act(() => {
+ root.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+}
+function start(view: ReturnType) {
+ fireEvent.mouseDown(view.getByTestId('clip'), { button: 0, clientX: 0, clientY: 40 })
+}
+function items() {
+ return structuredClone(useItemsStore.getState().items)
+}
+function expectIdle(before: TimelineItem[]) {
+ expect(items()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ expect(useSelectionStore.getState().dragState).toBeNull()
+ expect(useLinkedEditPreviewStore.getState().updatesById).toEqual({})
+}
+
+beforeEach(() => {
+ resetTimelineCompositionTestState()
+ useTimelineSettingsStore.setState({ fps: 30, snapEnabled: false, isDirty: false })
+ useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 })
+ useEditorStore.setState({ linkedSelectionEnabled: false, hostMode: false })
+ useSelectionStore.getState().clearSelection()
+ useItemsStore
+ .getState()
+ .setTracks([makeTimelineTrack({ id: 'track-v1', kind: 'video', name: 'Video', order: 0 })])
+ clip = makeTimelineVideoItem({
+ id: 'center',
+ trackId: 'track-v1',
+ from: 60,
+ durationInFrames: 60,
+ sourceStart: 30,
+ sourceEnd: 90,
+ sourceDuration: 300,
+ })
+ useItemsStore.getState().setItems([
+ makeTimelineVideoItem({
+ id: 'left',
+ trackId: 'track-v1',
+ from: 0,
+ durationInFrames: 60,
+ sourceStart: 30,
+ sourceEnd: 90,
+ sourceDuration: 300,
+ }),
+ clip,
+ makeTimelineVideoItem({
+ id: 'right',
+ trackId: 'track-v1',
+ from: 120,
+ durationInFrames: 60,
+ sourceStart: 30,
+ sourceEnd: 90,
+ sourceDuration: 300,
+ }),
+ ])
+ frames = new Map()
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ frames.set(++id, cb)
+ return id
+ })
+ vi.stubGlobal('cancelAnimationFrame', (key: number) => frames.delete(key))
+})
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('owner-scoped complete gesture lifecycle', () => {
+ it.each(['move', 'slip', 'slide'])(
+ 'cancels pending %s before threshold, including duplicate cancellation and late up',
+ (tool) => {
+ const view = mount(tool, true)
+ const before = items()
+ start(view)
+ cancel(view.getByTestId('root'))
+ cancel(view.getByTestId('root'))
+ move(6)
+ move(tool === 'trim' ? -15 : 15)
+ fireEvent.mouseUp(window)
+ expectIdle(before)
+ },
+ )
+ it.each(['move', 'trim', 'slip', 'slide', 'stretch'])(
+ 'cancels active %s and queued work before late up',
+ (tool) => {
+ const view = mount(tool)
+ const before = items()
+ start(view)
+ move(6)
+ move(tool === 'trim' ? -15 : 15)
+ expect(useSelectionStore.getState().dragState).not.toBeNull()
+ if (tool === 'slip') expect(useSlipEditPreviewStore.getState().slipDelta).not.toBe(0)
+ if (tool === 'slide') expect(useSlideEditPreviewStore.getState().slideDelta).not.toBe(0)
+ // Queue a final RAF move, then cancel synchronously before it runs.
+ fireEvent.mouseMove(window, { clientX: tool === 'trim' ? -18 : 18, clientY: 40 })
+ cancel(view.getByTestId('root'))
+ cancel(view.getByTestId('root'))
+ move(tool === 'trim' ? -20 : 20)
+ fireEvent.mouseUp(window)
+ expectIdle(before)
+ },
+ )
+ it.each(['move', 'trim', 'slip', 'slide', 'stretch'])(
+ 'foreign root and rerender preserve %s, commit once and undo restores',
+ (tool) => {
+ // Body movement needs free space; other edits use contiguous neighbors.
+ if (tool === 'move') useItemsStore.getState().setItems([clip])
+ const view = mount(tool)
+ const before = items()
+ start(view)
+ move(6)
+ move(tool === 'trim' ? -15 : 15)
+ view.rerender( )
+ cancel(view.getByTestId('foreign'))
+ cancel(view.getByTestId('outer'))
+ fireEvent.mouseUp(window)
+ fireEvent.mouseUp(window)
+ expect(items()).not.toEqual(before)
+ const edited = useItemsStore.getState().items.find((item) => item.id === 'center')!
+ if (tool === 'move' || tool === 'slide') expect(edited.from).toBe(75)
+ if (tool === 'trim') expect(edited.durationInFrames).toBe(45)
+ if (tool === 'slip') expect(edited.sourceStart).toBe(15)
+ if (tool === 'stretch') {
+ expect(edited.durationInFrames).toBe(75)
+ expect(edited.speed).toBe(0.8)
+ }
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1)
+ act(() => useTimelineCommandStore.getState().undo())
+ expect(items()).toEqual(before)
+ },
+ )
+ it.each(['move', 'trim', 'slip', 'slide', 'stretch'])(
+ 'true unmount cancels active %s',
+ (tool) => {
+ const view = mount(tool)
+ const before = items()
+ start(view)
+ move(6)
+ move(tool === 'trim' ? -15 : 15)
+ view.unmount()
+ move(25)
+ fireEvent.mouseUp(window)
+ expectIdle(before)
+ },
+ )
+})
+
+function CancellationOwner({ onCancel }: { onCancel: (update: boolean) => void }) {
+ const owner = useRef(null)
+ useTimelineGestureCancellation(owner, onCancel)
+ return (
+
+ )
+}
+
+it('keeps a stable listener with the current callback and disposes only on true unmount', () => {
+ const first = vi.fn()
+ const second = vi.fn()
+ const view = render( )
+ view.rerender( )
+ expect(first).not.toHaveBeenCalled()
+ expect(second).not.toHaveBeenCalled()
+ cancel(view.getByTestId('owner'))
+ expect(second).toHaveBeenCalledExactlyOnceWith(true)
+ view.unmount()
+ expect(second.mock.calls).toEqual([[true], [false]])
+ window.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture'))
+ expect(second).toHaveBeenCalledTimes(2)
+})
diff --git a/src/features/timeline/hooks/use-timeline-gesture-cancellation.ts b/src/features/timeline/hooks/use-timeline-gesture-cancellation.ts
new file mode 100644
index 000000000..d7a9fda9a
--- /dev/null
+++ b/src/features/timeline/hooks/use-timeline-gesture-cancellation.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef, type RefObject } from 'react'
+
+/** Retained editors cancel gestures without unmounting their timeline. */
+export function useTimelineGestureCancellation(
+ ownerRef: RefObject | undefined,
+ cancel: (updateReactState: boolean) => void,
+) {
+ const latest = useRef({ ownerRef, cancel })
+ latest.current = { ownerRef, cancel }
+
+ useEffect(() => {
+ const onCancel = (event: Event) => {
+ const owner = latest.current.ownerRef?.current
+ const root = owner?.closest(
+ '[data-editor-workspace-shell], [data-freecut-editor-surface], [role="application"]',
+ )
+ // No owner is not permission to cancel another editor's gesture.
+ if (!root || event.target !== root) return
+ latest.current.cancel(true)
+ }
+ window.addEventListener('freecut:cancel-timeline-gesture', onCancel)
+ return () => {
+ window.removeEventListener('freecut:cancel-timeline-gesture', onCancel)
+ latest.current.cancel(false)
+ }
+ }, [])
+}
diff --git a/src/features/timeline/hooks/use-timeline-slip-slide.ts b/src/features/timeline/hooks/use-timeline-slip-slide.ts
index f8d01f538..0ad9e1167 100644
--- a/src/features/timeline/hooks/use-timeline-slip-slide.ts
+++ b/src/features/timeline/hooks/use-timeline-slip-slide.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { TimelineItem } from '@/types/timeline'
import type { Transition } from '@/types/transition'
@@ -220,6 +221,7 @@ export function useTimelineSlipSlide(
item: TimelineItem,
timelineDuration: number,
trackLocked: boolean = false,
+ ownerRef?: React.RefObject,
) {
const pixelsToTime = pixelsToTimeNow
const fps = useTimelineStore((s) => s.fps)
@@ -230,7 +232,7 @@ export function useTimelineSlipSlide(
item.id,
)
- const [state, setState] = useState({
+ const [state, setReactState] = useState({
isActive: false,
mode: null,
startX: 0,
@@ -244,6 +246,11 @@ export function useTimelineSlipSlide(
const stateRef = useRef(state)
stateRef.current = state
+ const setState = useCallback((next: React.SetStateAction) => {
+ const value = typeof next === 'function' ? next(stateRef.current) : next
+ stateRef.current = value
+ setReactState(value)
+ }, [])
const latestDeltaRef = useRef(0)
const pendingStartCleanupRef = useRef<(() => void) | null>(null)
const slideGestureContextRef = useRef(null)
@@ -481,7 +488,15 @@ export function useTimelineSlipSlide(
// Note: clampSlideDelta intentionally omitted — it reads fps from store at
// call time, and including it would cause a TDZ error (defined after this hook).
},
- [buildSlideGestureContext, findNeighbors, fps, getItemFromStore, item.id, setDragState],
+ [
+ buildSlideGestureContext,
+ findNeighbors,
+ fps,
+ getItemFromStore,
+ item.id,
+ setDragState,
+ setState,
+ ],
)
/**
@@ -1150,28 +1165,20 @@ export function useTimelineSlipSlide(
clampSlideDelta,
clampSlideDeltaToPreserveTransitionsWithContext,
clampSlideDeltaWithContext,
+ setState,
getMagneticSnapTargets,
getSnapThresholdFrames,
isSnapEnabled,
],
)
- // Mouse up handler — commits changes
- const handleMouseUp = useCallback(() => {
- if (!stateRef.current.isActive) return
-
- const { mode, leftNeighborId, rightNeighborId } = stateRef.current
- const currentDelta = latestDeltaRef.current
-
- try {
- if (currentDelta !== 0) {
- if (mode === 'slip') {
- slipItem(item.id, currentDelta)
- } else if (mode === 'slide') {
- slideItem(item.id, currentDelta, leftNeighborId, rightNeighborId)
- }
- }
- } finally {
+ const cancelGesture = useCallback(
+ (updateReactState = true) => {
+ const pending = pendingStartCleanupRef.current
+ pendingStartCleanupRef.current = null
+ pending?.()
+ if (!stateRef.current.isActive) return
+ stateRef.current = { ...stateRef.current, isActive: false }
// Clear preview stores
useSlipEditPreviewStore.getState().clearPreview()
useSlideEditPreviewStore.getState().clearPreview()
@@ -1180,7 +1187,7 @@ export function useTimelineSlipSlide(
// Clear drag state
setDragState(null)
- setState({
+ const idle: SlipSlideState = {
isActive: false,
mode: null,
startX: 0,
@@ -1190,11 +1197,28 @@ export function useTimelineSlipSlide(
isConstrained: false,
constraintEdge: null,
constraintLabel: null,
- })
+ }
+ stateRef.current = idle
+ if (updateReactState) setState(idle)
latestDeltaRef.current = 0
slideGestureContextRef.current = null
+ },
+ [setDragState, setState],
+ )
+
+ useTimelineGestureCancellation(ownerRef, cancelGesture)
+
+ // Consume the gesture before invoking the existing command.
+ const handleMouseUp = useCallback(() => {
+ if (!stateRef.current.isActive) return
+ const { mode, leftNeighborId, rightNeighborId } = stateRef.current
+ const currentDelta = latestDeltaRef.current
+ cancelGesture()
+ if (currentDelta !== 0) {
+ if (mode === 'slip') slipItem(item.id, currentDelta)
+ else if (mode === 'slide') slideItem(item.id, currentDelta, leftNeighborId, rightNeighborId)
}
- }, [item.id, setDragState])
+ }, [item.id, cancelGesture])
// Setup/cleanup mouse event listeners
useEffect(() => {
@@ -1209,23 +1233,6 @@ export function useTimelineSlipSlide(
}
}, [state.isActive, handleMouseMove, handleMouseUp])
- useEffect(
- () => () => {
- pendingStartCleanupRef.current?.()
- // Effect dependency changes may replace the window listeners during an
- // active gesture. Only a true unmount should abandon its preview state.
- if (stateRef.current.isActive) {
- useSlipEditPreviewStore.getState().clearPreview()
- useSlideEditPreviewStore.getState().clearPreview()
- useLinkedEditPreviewStore.getState().clear()
- useSelectionStore.getState().setDragState(null)
- latestDeltaRef.current = 0
- }
- slideGestureContextRef.current = null
- },
- [],
- )
-
// Start slip/slide drag
const handleSlipSlideStart = useCallback(
(e: React.MouseEvent, mode: 'slip' | 'slide', options?: SlipSlideStartOptions) => {
diff --git a/src/features/timeline/hooks/use-timeline-trim.test.tsx b/src/features/timeline/hooks/use-timeline-trim.test.tsx
index e9b8eb1ab..f335a0413 100644
--- a/src/features/timeline/hooks/use-timeline-trim.test.tsx
+++ b/src/features/timeline/hooks/use-timeline-trim.test.tsx
@@ -75,7 +75,10 @@ function getItem(id: string): TimelineItem {
}
function renderTrimHook(item: TimelineItem, trackLocked = false) {
- return renderHook(() => useTimelineTrim(item, TIMELINE_DURATION, trackLocked))
+ document.body.setAttribute('data-editor-workspace-shell', '')
+ return renderHook(() =>
+ useTimelineTrim(item, TIMELINE_DURATION, trackLocked, { current: document.body }),
+ )
}
interface StartOptions {
@@ -142,7 +145,10 @@ describe('useTimelineTrim', () => {
setupStores()
})
- afterEach(() => vi.unstubAllGlobals())
+ afterEach(() => {
+ document.body.removeAttribute('data-editor-workspace-shell')
+ vi.unstubAllGlobals()
+ })
describe('semantic host gesture', () => {
function setupHost() {
@@ -186,7 +192,8 @@ describe('useTimelineTrim', () => {
if (reason === 'unmount') unmount()
else if (reason === 'Escape')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
- else if (reason !== 'zero delta') window.dispatchEvent(new Event(reason))
+ else if (reason !== 'zero delta')
+ document.body.dispatchEvent(new Event(reason, { bubbles: true }))
})
releaseMouse()
expect(port.cancelTrim).toHaveBeenCalledExactlyOnceWith('gesture-1')
@@ -221,6 +228,7 @@ describe('useTimelineTrim', () => {
it('only cancels when the collapsing surface owns this clip', () => {
const { port, clip } = setupHost()
const surface = document.createElement('div')
+ surface.setAttribute('data-editor-workspace-shell', '')
const other = document.createElement('div')
const owner = document.createElement('div')
surface.append(owner)
@@ -856,7 +864,7 @@ describe('useTimelineTrim', () => {
if (cancellation === 'Escape') {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
} else {
- window.dispatchEvent(new Event(cancellation))
+ document.body.dispatchEvent(new Event(cancellation, { bubbles: true }))
}
})
diff --git a/src/features/timeline/hooks/use-timeline-trim.ts b/src/features/timeline/hooks/use-timeline-trim.ts
index 9fa837099..fe30d3659 100644
--- a/src/features/timeline/hooks/use-timeline-trim.ts
+++ b/src/features/timeline/hooks/use-timeline-trim.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { TimelineItem } from '@/types/timeline'
import { commitPreviewFrameToCurrentFrame } from '@/shared/state/playback'
@@ -197,7 +198,6 @@ export function useTimelineTrim(
hostGestureRef.current = null
if (gesture) gesture.port.cancelTrim(gesture.token)
}, [])
- useEffect(() => cancelHostGesture, [cancelHostGesture])
const pixelsToTime = pixelsToTimeNow
const fps = useTimelineStore((s) => s.fps)
const setDragState = useSelectionStore((s) => s.setDragState)
@@ -217,6 +217,7 @@ export function useTimelineTrim(
const [trimState, setTrimState] = useState(createIdleTrimState)
+ const cancelQueuedMoveRef = useRef<(() => void) | null>(null)
const trimStateRef = useRef(trimState)
trimStateRef.current = trimState
@@ -820,12 +821,22 @@ export function useTimelineTrim(
setTrimState(idleState)
}, [])
- const handleTrimCancel = useCallback(() => {
- if (!trimStateRef.current.isTrimming) return
- cancelHostGesture()
- clearTrimPresentation()
- resetTrimState()
- }, [cancelHostGesture, clearTrimPresentation, resetTrimState])
+ const handleTrimCancel = useCallback(
+ (updateReactState = true) => {
+ if (!trimStateRef.current.isTrimming) return
+ trimStateRef.current = createIdleTrimState()
+ cancelQueuedMoveRef.current?.()
+ try {
+ cancelHostGesture()
+ } finally {
+ clearTrimPresentation()
+ if (updateReactState) resetTrimState()
+ }
+ },
+ [cancelHostGesture, clearTrimPresentation, resetTrimState],
+ )
+
+ useTimelineGestureCancellation(ownerRef, handleTrimCancel)
// Mouse up handler - commits changes to store (single update)
const handleMouseUp = useCallback(() => {
@@ -928,19 +939,11 @@ export function useTimelineTrim(
handleTrimCancel()
}
- const handleEditorCollapse = (event: Event) => {
- if (
- ownerRef?.current &&
- (!(event.target instanceof Element) || !event.target.contains(ownerRef.current))
- )
- return
- handlePointerCancel()
- }
+ cancelQueuedMoveRef.current = coalescedMouseMove.cancel
window.addEventListener('mousemove', coalescedMouseMove.queue)
window.addEventListener('mouseup', handleCoalescedMouseUp)
window.addEventListener('pointercancel', handlePointerCancel)
- window.addEventListener('freecut:cancel-timeline-gesture', handleEditorCollapse)
window.addEventListener('blur', handlePointerCancel)
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
@@ -949,9 +952,9 @@ export function useTimelineTrim(
window.removeEventListener('mousemove', coalescedMouseMove.queue)
window.removeEventListener('mouseup', handleCoalescedMouseUp)
window.removeEventListener('pointercancel', handlePointerCancel)
- window.removeEventListener('freecut:cancel-timeline-gesture', handleEditorCollapse)
window.removeEventListener('blur', handlePointerCancel)
coalescedMouseMove.cancel()
+ cancelQueuedMoveRef.current = null
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
clearTrimPresentation()
@@ -960,7 +963,6 @@ export function useTimelineTrim(
}
}, [
trimState.isTrimming,
- ownerRef,
clearTrimPresentation,
handleMouseMove,
handleMouseUp,
diff --git a/src/features/timeline/hooks/use-track-push.dom.test.tsx b/src/features/timeline/hooks/use-track-push.dom.test.tsx
new file mode 100644
index 000000000..1bae58006
--- /dev/null
+++ b/src/features/timeline/hooks/use-track-push.dom.test.tsx
@@ -0,0 +1,111 @@
+import { useRef } from 'react'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
+import { useItemsStore } from '../stores/items-store'
+import { useTimelineCommandStore } from '../stores/timeline-command-store'
+import { useTimelineSettingsStore } from '../stores/timeline-settings-store'
+import { useZoomStore } from '../stores/zoom-store'
+import { useTrackPushPreviewStore } from '../stores/track-push-preview-store'
+import { useSelectionStore } from '@/shared/state/selection'
+import {
+ makeTimelineTrack,
+ makeTimelineVideoItem,
+ resetTimelineCompositionTestState,
+} from '../test-helpers'
+import { TrackPushHandle } from '../components/timeline-item/track-push-handle'
+import { useTrackPush } from './use-track-push'
+function Surface() {
+ const owner = useRef(null)
+ const item = useItemsStore((s) => s.items.find((i) => i.id === 'anchor')!)
+ const push = useTrackPush(item, 600, false, owner)
+ return (
+
+ )
+}
+function cancel(el: Element) {
+ act(() => {
+ el.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+}
+function snapshot() {
+ return structuredClone(useItemsStore.getState().items)
+}
+function start(view: ReturnType) {
+ fireEvent.mouseDown(view.container.querySelector('[data-track-push]')!, { button: 0, clientX: 0 })
+}
+beforeEach(() => {
+ resetTimelineCompositionTestState()
+ useTimelineSettingsStore.setState({ fps: 30, snapEnabled: false })
+ useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 })
+ useItemsStore
+ .getState()
+ .setTracks([
+ makeTimelineTrack({ id: 'v', name: 'V1', order: 0 }),
+ makeTimelineTrack({ id: 'v2', name: 'V2', order: 1 }),
+ ])
+ useItemsStore
+ .getState()
+ .setItems([
+ makeTimelineVideoItem({ id: 'static', trackId: 'v', from: 0, durationInFrames: 30 }),
+ makeTimelineVideoItem({ id: 'anchor', trackId: 'v', from: 60 }),
+ makeTimelineVideoItem({ id: 'later', trackId: 'v2', from: 90 }),
+ ])
+})
+afterEach(cleanup)
+describe('track push cancellation', () => {
+ it.each([0, 15, -15])('cancels press/active delta %s before late release', (delta) => {
+ const view = render( ),
+ before = snapshot()
+ start(view)
+ if (delta) fireEvent.mouseMove(window, { clientX: delta })
+ expect(useTrackPushPreviewStore.getState().delta).toBe(delta)
+ cancel(view.getByTestId('root'))
+ cancel(view.getByTestId('root'))
+ fireEvent.mouseMove(window, { clientX: 25 })
+ fireEvent.mouseUp(window)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ expect(useTrackPushPreviewStore.getState().anchorItemId).toBeNull()
+ expect(useSelectionStore.getState().dragState).toBeNull()
+ })
+ it.each([15, -15])('foreign roots/rerender preserve normal delta %s and one undo', (delta) => {
+ const view = render( ),
+ before = snapshot()
+ start(view)
+ fireEvent.mouseMove(window, { clientX: delta })
+ view.rerender( )
+ cancel(view.getByTestId('foreign'))
+ cancel(view.getByTestId('outer'))
+ fireEvent.mouseUp(window)
+ fireEvent.mouseUp(window)
+ expect(useItemsStore.getState().items.find((i) => i.id === 'anchor')?.from).toBe(60 + delta)
+ expect(useItemsStore.getState().items.find((i) => i.id === 'later')?.from).toBe(90 + delta)
+ expect(useItemsStore.getState().items.find((i) => i.id === 'static')?.from).toBe(0)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1)
+ act(() => useTimelineCommandStore.getState().undo())
+ expect(snapshot()).toEqual(before)
+ })
+ it('unmount cancels active push', () => {
+ const view = render( ),
+ before = snapshot()
+ start(view)
+ fireEvent.mouseMove(window, { clientX: 15 })
+ view.unmount()
+ fireEvent.mouseUp(window)
+ expect(snapshot()).toEqual(before)
+ expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0)
+ expect(useSelectionStore.getState().dragState).toBeNull()
+ })
+})
diff --git a/src/features/timeline/hooks/use-track-push.ts b/src/features/timeline/hooks/use-track-push.ts
index ce1456b52..5db803418 100644
--- a/src/features/timeline/hooks/use-track-push.ts
+++ b/src/features/timeline/hooks/use-track-push.ts
@@ -1,3 +1,4 @@
+import { useTimelineGestureCancellation } from './use-timeline-gesture-cancellation'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { TimelineItem } from '@/types/timeline'
import { commitPreviewFrameToCurrentFrame } from '@/shared/state/playback'
@@ -30,6 +31,7 @@ export function useTrackPush(
item: TimelineItem,
timelineDuration: number,
trackLocked: boolean = false,
+ ownerRef?: React.RefObject,
) {
const pixelsToTime = pixelsToTimeNow
const fps = useTimelineStore((s) => s.fps)
@@ -40,7 +42,7 @@ export function useTrackPush(
item.id,
)
- const [state, setState] = useState({
+ const [state, setReactState] = useState({
isActive: false,
startX: 0,
currentDelta: 0,
@@ -48,6 +50,11 @@ export function useTrackPush(
})
const stateRef = useRef(state)
stateRef.current = state
+ const setState = useCallback((next: React.SetStateAction) => {
+ const value = typeof next === 'function' ? next(stateRef.current) : next
+ stateRef.current = value
+ setReactState(value)
+ }, [])
const prevSnapTargetRef = useRef<{ frame: number; type: string } | null>(null)
const magneticSnapTargetsRef = useRef([])
@@ -107,22 +114,32 @@ export function useTrackPush(
setActiveSnapTarget,
})
},
- [pixelsToTime, fps, trackLocked, findSnapForFrame, setActiveSnapTarget, item.from],
+ [pixelsToTime, fps, trackLocked, findSnapForFrame, setActiveSnapTarget, item.from, setState],
)
+ const cancelGesture = useCallback(
+ (updateReactState = true) => {
+ if (!stateRef.current.isActive) return
+ const idle = { isActive: false, startX: 0, currentDelta: 0, maxLeftFrames: 0 }
+ stateRef.current = idle
+ useTrackPushPreviewStore.getState().clearPreview()
+ prevSnapTargetRef.current = null
+ magneticSnapTargetsRef.current = []
+ setActiveSnapTarget(null)
+ setDragState(null)
+ if (updateReactState) setState(idle)
+ },
+ [setActiveSnapTarget, setDragState, setState],
+ )
+
+ useTimelineGestureCancellation(ownerRef, cancelGesture)
+
const handleMouseUp = useCallback(() => {
if (!stateRef.current.isActive) return
const delta = stateRef.current.currentDelta
- if (delta !== 0) {
- trackPushItems(item.id, delta)
- }
- useTrackPushPreviewStore.getState().clearPreview()
- setActiveSnapTarget(null)
- setDragState(null)
- prevSnapTargetRef.current = null
- magneticSnapTargetsRef.current = []
- setState({ isActive: false, startX: 0, currentDelta: 0, maxLeftFrames: 0 })
- }, [item.id, setActiveSnapTarget, setDragState])
+ cancelGesture()
+ if (delta !== 0) trackPushItems(item.id, delta)
+ }, [item.id, cancelGesture])
useEffect(() => {
if (state.isActive) {
@@ -131,10 +148,6 @@ export function useTrackPush(
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
- useTrackPushPreviewStore.getState().clearPreview()
- magneticSnapTargetsRef.current = []
- setActiveSnapTarget(null)
- setDragState(null)
}
}
}, [state.isActive, handleMouseMove, handleMouseUp, setActiveSnapTarget, setDragState])
@@ -217,6 +230,7 @@ export function useTrackPush(
setActiveSnapTarget,
setDragState,
getMagneticSnapTargets,
+ setState,
],
)
diff --git a/src/i18n/locales/partials/en/editor.json b/src/i18n/locales/partials/en/editor.json
index 3a6b64193..f7d79bcdd 100644
--- a/src/i18n/locales/partials/en/editor.json
+++ b/src/i18n/locales/partials/en/editor.json
@@ -883,6 +883,31 @@
"dockTitle": "Color",
"emptyState": "Select a clip to grade",
"adjustmentLayerLabel": "Grade"
+ },
+ "refresh": {
+ "columns": "Workspace columns",
+ "view": "View",
+ "audioMeters": "Audio meters",
+ "library": "Library",
+ "editor": "Editor",
+ "resizeLibrary": "Resize Library",
+ "hideLibrary": "Hide Library",
+ "showLibrary": "Show Library",
+ "hideEditor": "Hide Editor",
+ "showEditor": "Show Editor",
+ "collapseEditor": "Collapse Editor",
+ "collapseLibrary": "Collapse Library",
+ "settings": "Settings",
+ "closeSettings": "Close settings",
+ "clipSettings": "Clip settings",
+ "canvasSettings": "Canvas settings",
+ "restoreHint": "Show Library or Editor to continue editing.",
+ "selectionChanged": "The selected clip changed. Settings closed; select a clip to continue.",
+ "libraryTools": "Library tools",
+ "moreTools": "More library tools",
+ "more": "More",
+ "generateTranscript": "Generate transcript",
+ "helpSettings": "Help & settings"
}
}
}
diff --git a/src/shared/state/mixer-live-gain.test.tsx b/src/shared/state/mixer-live-gain.test.tsx
index 54b699cb4..7dcc1271a 100644
--- a/src/shared/state/mixer-live-gain.test.tsx
+++ b/src/shared/state/mixer-live-gain.test.tsx
@@ -2,6 +2,7 @@ import { act, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import {
clearMixerLiveGain,
+ getDefaultMixerLiveGain,
clearMixerLiveGainLayer,
clearMixerLiveGains,
setMixerLiveGainLayer,
@@ -137,4 +138,14 @@ describe('mixer-live-gain', () => {
expect(screen.getByTestId('gain-a').textContent).toBe('1')
})
+ it('reads the default layer independently of a zero or changing foreign layer', () => {
+ expect(getDefaultMixerLiveGain('item-a')).toBe(1)
+ setMixerLiveGains([{ itemId: 'item-a', gain: 1.7 }])
+ setMixerLiveGainLayer('mute', [{ itemId: 'item-a', gain: 0 }])
+ expect(getDefaultMixerLiveGain('item-a')).toBe(1.7)
+ setMixerLiveGainLayer('mute', [{ itemId: 'item-a', gain: 0.8 }])
+ expect(getDefaultMixerLiveGain('item-a')).toBe(1.7)
+ setMixerLiveGains([{ itemId: 'item-a', gain: 1 }])
+ expect(getDefaultMixerLiveGain('item-a')).toBe(1)
+ })
})
diff --git a/src/shared/state/mixer-live-gain.ts b/src/shared/state/mixer-live-gain.ts
index 04713d8d3..4500c85ea 100644
--- a/src/shared/state/mixer-live-gain.ts
+++ b/src/shared/state/mixer-live-gain.ts
@@ -141,6 +141,11 @@ export function clearMixerLiveGain(itemId: string): void {
}
}
+/** Read the layer owned by setMixerLiveGains, independently of mute/other layers. */
+export function getDefaultMixerLiveGain(itemId: string): number {
+ return overridesByLayerId.get(DEFAULT_LAYER_ID)?.get(itemId) ?? 1
+}
+
export function getMixerLiveGain(itemId: string): number {
return getMixerLiveGainForItemAcrossLayers(itemId)
}
diff --git a/tests/browser/layout-refresh.html b/tests/browser/layout-refresh.html
new file mode 100644
index 000000000..88246f850
--- /dev/null
+++ b/tests/browser/layout-refresh.html
@@ -0,0 +1 @@
+Editor layout review
diff --git a/tests/browser/layout-refresh.spec.ts b/tests/browser/layout-refresh.spec.ts
new file mode 100644
index 000000000..1ab5b6032
--- /dev/null
+++ b/tests/browser/layout-refresh.spec.ts
@@ -0,0 +1,231 @@
+import { execFileSync } from 'node:child_process'
+import { unlinkSync } from 'node:fs'
+import { expect, test } from 'playwright/test'
+
+const fixtureMedia = 'tests/browser/.layout-refresh-generated.webm'
+test.beforeAll(() =>
+ execFileSync(
+ 'ffmpeg',
+ [
+ '-y',
+ '-f',
+ 'lavfi',
+ '-i',
+ 'testsrc2=size=320x180:rate=30:duration=4',
+ '-f',
+ 'lavfi',
+ '-i',
+ 'sine=frequency=440:duration=4',
+ '-c:v',
+ 'libvpx-vp9',
+ '-c:a',
+ 'libopus',
+ fixtureMedia,
+ ],
+ { stdio: 'ignore' },
+ ),
+)
+test.afterAll(() => unlinkSync(fixtureMedia))
+
+test('independent columns preserve draft/search and restore from every combination', async ({
+ page,
+}) => {
+ await page.goto('/tests/browser/layout-refresh.html')
+ await page.locator('[data-item-id]').first().waitFor()
+ await page.getByRole('button', { name: 'Transcript', exact: true }).click()
+ await expect(
+ page.getByText('A calmer workspace makes room for your ideas.', { exact: true }),
+ ).toBeVisible()
+ await page.getByRole('textbox', { name: 'Chat draft' }).fill('Keep this unsent draft')
+ const search = page.getByPlaceholder('Search transcript')
+ await search.fill('keep')
+ for (const width of [1440, 1280, 760]) {
+ await page.setViewportSize({ width, height: 900 })
+ await page.screenshot({ path: `/tmp/ux7144-pr1-${width}-expanded.png` })
+ for (const column of ['Chat', 'Library', 'Editor']) {
+ await page.getByRole('button', { name: `Hide ${column}`, exact: true }).click()
+ await page.screenshot({
+ path: `/tmp/ux7144-pr1-${width}-${column.toLowerCase()}-collapsed.png`,
+ })
+ await page.getByRole('button', { name: `Show ${column}`, exact: true }).click()
+ }
+ await expect(page.getByRole('textbox', { name: 'Chat draft' })).toHaveValue(
+ 'Keep this unsent draft',
+ )
+ await expect(search).toHaveValue('keep')
+ }
+ await page.setViewportSize({ width: 760, height: 900 })
+ await page.getByRole('button', { name: 'Hide Chat', exact: true }).click()
+ await page.getByRole('button', { name: 'Hide Library', exact: true }).click()
+ await page.screenshot({ path: '/tmp/ux7144-pr1-760-editor-focused.png' })
+ const exportBounds = await page.getByRole('button', { name: 'Export', exact: true }).boundingBox()
+ expect(exportBounds!.x + exportBounds!.width).toBeLessThanOrEqual(760)
+ await page.getByRole('button', { name: 'Show Chat', exact: true }).click()
+ await page.getByRole('button', { name: 'Show Library', exact: true }).click()
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.getByRole('button', { name: 'Hide Editor', exact: true }).click()
+ const libraryWidth = await page
+ .locator('[data-editor-column="library"]')
+ .evaluate((element) => element.getBoundingClientRect().width)
+ expect(libraryWidth).toBeGreaterThan(1000)
+ await page.getByRole('button', { name: 'Hide Library', exact: true }).click()
+ await page.getByRole('button', { name: 'Hide Chat', exact: true }).click()
+ await page.screenshot({ path: '/tmp/ux7144-pr1-all-collapsed.png' })
+ for (const column of ['Chat', 'Library', 'Editor'])
+ await page.getByRole('button', { name: `Show ${column}`, exact: true }).click()
+ const separator = page.getByRole('separator', { name: 'Resize Library' })
+ await expect(page.getByText('00:00:00', { exact: true })).toBeVisible()
+ const clipCount = await page.locator('[data-item-id]').count()
+ await separator.focus()
+ await page.keyboard.press('ArrowRight')
+ await expect(separator).toHaveAttribute('aria-valuenow', '296')
+ await expect(page.getByText('00:00:00', { exact: true })).toBeVisible()
+ await page.keyboard.press('Space')
+ await page.keyboard.press('Backspace')
+ expect(await page.locator('[data-item-id]').count()).toBe(clipCount)
+ expect(
+ await page
+ .locator('video')
+ .evaluateAll((videos) => videos.every((video) => (video as HTMLVideoElement).paused)),
+ ).toBe(true)
+ await page.getByRole('button', { name: 'Canvas settings', exact: true }).click()
+ await expect(page.getByRole('region', { name: 'Settings', exact: true })).toBeVisible()
+ await page.screenshot({ path: '/tmp/ux7144-pr1-settings.png' })
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('region', { name: 'Settings', exact: true })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Canvas settings', exact: true })).toBeFocused()
+ await expect(page.getByRole('button', { name: 'Export', exact: true })).toHaveCount(1)
+ await expect(page.getByRole('button', { name: 'Undo', exact: true })).toHaveCount(1)
+ await expect(page.getByRole('button', { name: 'Redo', exact: true })).toHaveCount(1)
+})
+
+test('hiding Editor pauses a playing source monitor without losing its frame', async ({ page }) => {
+ await page.goto('/tests/browser/layout-refresh.html')
+ await page.locator('[data-item-id]').first().waitFor()
+ await page.getByRole('button', { name: 'Media', exact: true }).click()
+ await page.getByText('generated-source-range.webm', { exact: true }).first().dblclick()
+ await page.getByRole('button', { name: 'Close source monitor', exact: true }).waitFor()
+ const sourceState = () =>
+ page.evaluate(async (modulePath) => {
+ const { useSourcePlayerStore } = await import(modulePath)
+ const state = useSourcePlayerStore.getState()
+ return { playing: state.playerMethods?.isPlaying(), frame: state.currentSourceFrame }
+ }, '/src/shared/state/source-player/store.ts')
+ const timelineState = () =>
+ page.evaluate(async (modulePath) => {
+ const { usePlaybackStore } = await import(modulePath)
+ return usePlaybackStore.getState().isPlaying
+ }, '/src/shared/state/playback/index.ts')
+ await page.getByRole('button', { name: 'Play (Space)', exact: true }).click()
+ await expect.poll(async () => (await sourceState()).playing).toBe(true)
+ await expect.poll(async () => (await sourceState()).frame).toBeGreaterThan(0)
+ expect(await timelineState()).toBe(false)
+ await page.getByRole('button', { name: 'Hide Editor', exact: true }).click()
+ await expect(page.locator('[data-editor-column="editor"]')).toBeHidden()
+ expect((await sourceState()).playing).toBe(false)
+ const pausedFrame = (await sourceState()).frame
+ // Observe beyond several real source clock ticks, not just the pause callback.
+ await page.waitForTimeout(250)
+ expect((await sourceState()).frame).toBe(pausedFrame)
+ await page.getByRole('button', { name: 'Show Editor', exact: true }).click()
+ expect((await sourceState()).frame).toBe(pausedFrame)
+ expect((await sourceState()).playing).toBe(false)
+ await expect(
+ page.getByRole('button', { name: 'Close source monitor', exact: true }),
+ ).toBeVisible()
+})
+
+test('host removal closes stale settings without stealing chat focus', async ({ page }) => {
+ await page.goto('/tests/browser/layout-refresh.html')
+ const clip = page.locator('[data-timeline-item][data-item-id="retained-video"]').first()
+ await clip.click()
+ await page.getByRole('button', { name: 'Clip settings', exact: true }).click()
+ const chat = page.getByRole('textbox', { name: 'Chat draft' })
+ await chat.fill('Keep typing')
+ await page.evaluate(() => window.__layoutHarness.removeClip())
+ await expect(page.getByRole('region', { name: 'Settings', exact: true })).toHaveCount(0)
+ await expect(chat).toBeFocused()
+ await page.keyboard.type(' here')
+ await expect(chat).toHaveValue('Keep typing here')
+})
+
+test('actual Hide Editor cancels a held body move before late mouseup', async ({ page }) => {
+ await page.goto('/tests/browser/layout-refresh.html')
+ await page.getByRole('button', { name: 'Hide Library', exact: true }).click()
+ const clip = page.locator('[data-timeline-item][data-item-id="retained-video"]').first()
+ await clip.waitFor()
+ const bounds = await clip.boundingBox()
+ const x = bounds!.x + Math.min(bounds!.width / 2, 80)
+ const y = bounds!.y + bounds!.height / 2
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ await page.mouse.move(x + 110, y, { steps: 6 })
+ await expect
+ .poll(() => clip.evaluate((element) => (element.parentElement as HTMLElement).style.transform))
+ .not.toBe('')
+ await expect.poll(() => page.evaluate(() => window.__layoutHarness.state().dragging)).toBe(true)
+ await page.getByRole('button', { name: 'Hide Editor', exact: true }).focus()
+ await page.keyboard.press('Enter')
+ await expect(page.locator('[data-editor-column="editor"]')).toBeHidden()
+ await expect.poll(() => page.evaluate(() => window.__layoutHarness.state().dragging)).toBe(false)
+ await page.getByRole('button', { name: 'Show Editor', exact: true }).focus()
+ await page.keyboard.press('Enter')
+ await page.mouse.up()
+ await expect(clip).toBeVisible()
+ const state = await page.evaluate(() => window.__layoutHarness.state())
+ expect(state.submitCount).toBe(0)
+ expect(state.snapshot.timeline.tracks[0]!.items[0]!.from).toBe(0)
+})
+
+for (const ownership of ['first', 'second']) {
+ for (const gesture of ['body', 'trim']) {
+ test(`${gesture} cancellation stays within ${ownership} shell and tolerates duplicates`, async ({
+ page,
+ }) => {
+ await page.goto(`/tests/browser/layout-refresh.html?containment=${ownership}`)
+ const owner = page
+ .locator('[data-freecut-editor-surface="host"] [data-editor-workspace-shell]')
+ .first()
+ const other = page.locator('#other-shell [data-editor-workspace-shell]')
+ await expect(other).toBeVisible()
+ await owner.getByRole('button', { name: 'Hide Library', exact: true }).click()
+ const clip = owner.locator('[data-timeline-item][data-item-id="retained-video"]').first()
+ await clip.waitFor()
+ const target = gesture === 'trim' ? owner.locator('[data-trim-handle="end"]').first() : clip
+ await clip.click()
+ await clip.hover()
+ const box = (await target.boundingBox())!
+ const x = box.x + Math.min(box.width / 2, 80)
+ const y = box.y + box.height / 2
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ await page.mouse.move(x + (gesture === 'trim' ? -30 : 110), y, { steps: 8 })
+ const preview = () =>
+ gesture === 'body'
+ ? page.evaluate(() => window.__layoutHarness.state().dragging)
+ : owner
+ .locator('[data-trim-preview-ghost]')
+ .count()
+ .then((count) => count > 0)
+ await expect.poll(preview).toBe(true)
+ await other.evaluate((root) =>
+ root.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true })),
+ )
+ await expect.poll(preview).toBe(true)
+ await owner.getByRole('button', { name: 'Hide Editor', exact: true }).focus()
+ await page.keyboard.press('Enter')
+ await owner.evaluate((root) => {
+ root.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ root.dispatchEvent(new CustomEvent('freecut:cancel-timeline-gesture', { bubbles: true }))
+ })
+ await expect.poll(preview).toBe(false)
+ await page.mouse.up()
+ await owner.getByRole('button', { name: 'Show Editor', exact: true }).click()
+ const state = await page.evaluate(() => window.__layoutHarness.state())
+ expect(state.submitCount).toBe(0)
+ expect(state.snapshot.timeline.revision).toBe(0)
+ expect(state.snapshot.timeline.tracks[0]!.items[0]!.from).toBe(0)
+ expect(state.snapshot.timeline.tracks[0]!.items[0]!.durationInFrames).toBe(60)
+ })
+ }
+}
diff --git a/tests/browser/layout-refresh.tsx b/tests/browser/layout-refresh.tsx
new file mode 100644
index 000000000..e25ba4070
--- /dev/null
+++ b/tests/browser/layout-refresh.tsx
@@ -0,0 +1,297 @@
+// fallow-ignore-file unused-file
+import { useState } from 'react'
+import { useSelectionStore } from '../../src/shared/state/selection'
+import { createPortal } from 'react-dom'
+import { EditorWorkspaceShell } from '../../src/features/editor/components/editor-workspace-shell'
+import { createRoot } from 'react-dom/client'
+import { FreeCutEditorSurface } from '../../src/features/editor/host/editor-surface'
+import {
+ DEFAULT_HOST_CAPABILITIES,
+ type EditorHost,
+ type EmbeddedEditorSnapshot,
+} from '../../src/features/editor/host/contract'
+import {
+ createCodePressCommandAdapter,
+ freeCutDocumentToControlledDocument,
+ controlledDocumentToFreeCutDocument,
+} from '../../src/features/editor/codepress'
+
+const MEDIA_ID = 'generated-av'
+const ITEM_ID = 'retained-video'
+const MEDIA_SOURCE = '/tests/browser/.layout-refresh-generated.webm'
+
+function snapshot(
+ revision: number,
+ sourceStart: number,
+ sourceEnd: number,
+): EmbeddedEditorSnapshot {
+ return {
+ project: {
+ id: 'source-range-project',
+ name: 'Retained source range',
+ width: 320,
+ height: 180,
+ fps: 30,
+ backgroundColor: '#000000',
+ },
+ timeline: {
+ timelineId: 'source-range-timeline',
+ revision,
+ fps: 30,
+ durationInFrames: 60,
+ media: [
+ {
+ media_id: MEDIA_ID,
+ media_kind: 'video',
+ content_hash: 'sha256:generated-source-range',
+ duration_us: 4_000_000,
+ availability: { mode: 'cloud', cloud: { object_id: 'generated-source-range' } },
+ },
+ ],
+ tracks: [
+ {
+ id: 'video-track',
+ kind: 'video',
+ name: 'V1',
+ locked: false,
+ muted: false,
+ items: [
+ {
+ id: ITEM_ID,
+ type: 'video',
+ trackId: 'video-track',
+ mediaId: MEDIA_ID,
+ from: 0,
+ durationInFrames: 60,
+ sourceStart,
+ sourceEnd,
+ },
+ ],
+ },
+ ],
+ width: 320,
+ height: 180,
+ backgroundColor: '#000000',
+ },
+ assets: [
+ {
+ id: MEDIA_ID,
+ kind: 'video',
+ fileName: 'generated-source-range.webm',
+ mimeType: 'video/webm',
+ durationSeconds: 4,
+ width: 320,
+ height: 180,
+ fps: 30,
+ contentHash: 'sha256:generated-source-range',
+ },
+ ],
+ }
+}
+
+let currentSnapshot = snapshot(0, 0, 60)
+let loadCount = 0
+let submitCount = 0
+let adapter = createCodePressCommandAdapter({
+ document: freeCutDocumentToControlledDocument(currentSnapshot.timeline),
+})
+const listeners = new Set<(value: EmbeddedEditorSnapshot) => void>()
+
+const host: EditorHost = {
+ capabilities: { ...DEFAULT_HOST_CAPABILITIES, 'media.transcription': true },
+ transcript: {
+ getStatus: () => ({
+ transcriptId: 'layout-transcript',
+ assetId: MEDIA_ID,
+ sourceAssetHash: 'sha256:generated-source-range',
+ status: 'succeeded',
+ durationUs: 4_000_000,
+ sectionCount: 2,
+ }),
+ getSections: () => ({
+ transcriptId: 'layout-transcript',
+ hasMore: false,
+ sections: [
+ {
+ id: 'section-one',
+ transcriptId: 'layout-transcript',
+ ordinal: 0,
+ startUs: 0,
+ endUs: 1_000_000,
+ text: 'A calmer workspace makes room for your ideas.',
+ },
+ {
+ id: 'section-two',
+ transcriptId: 'layout-transcript',
+ ordinal: 1,
+ startUs: 1_000_000,
+ endUs: 2_000_000,
+ text: 'Keep the conversation beside the edit.',
+ },
+ ],
+ }),
+ search: () => ({ transcriptId: 'layout-transcript', query: '', hasMore: false, sections: [] }),
+ previewCommands: () => {
+ throw new Error('Read-only layout fixture')
+ },
+ },
+ load: () => {
+ loadCount += 1
+ return currentSnapshot
+ },
+ resolveMedia: ({ mediaId }) => (mediaId === MEDIA_ID ? { source: MEDIA_SOURCE } : null),
+ submitEdit: (batch) => {
+ submitCount += 1
+ const result = adapter.apply(batch)
+ if (result.status === 'rejected')
+ return { status: 'rejected', snapshot: currentSnapshot, result }
+ currentSnapshot = {
+ ...currentSnapshot,
+ timeline: controlledDocumentToFreeCutDocument(adapter.getDocument()),
+ }
+ return { status: result.status, snapshot: currentSnapshot, result }
+ },
+ subscribe: (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+}
+
+declare global {
+ interface Window {
+ __layoutHarness: {
+ state(): { snapshot: EmbeddedEditorSnapshot; submitCount: number; dragging: boolean }
+ removeClip(): void
+ }
+ }
+}
+window.__layoutHarness = {
+ state: () => ({
+ snapshot: currentSnapshot,
+ submitCount,
+ dragging: useSelectionStore.getState().dragState?.isDragging === true,
+ }),
+ removeClip: () => {
+ currentSnapshot = {
+ ...currentSnapshot,
+ timeline: {
+ ...currentSnapshot.timeline,
+ revision: currentSnapshot.timeline.revision + 1,
+ tracks: currentSnapshot.timeline.tracks.map(
+ (track: EmbeddedEditorSnapshot['timeline']['tracks'][number]) => ({
+ ...track,
+ items: [],
+ }),
+ ),
+ },
+ }
+ adapter = createCodePressCommandAdapter({
+ document: freeCutDocumentToControlledDocument(currentSnapshot.timeline),
+ })
+ listeners.forEach((listener) => listener(currentSnapshot))
+ },
+}
+
+export const containment = new URLSearchParams(location.search).get('containment')
+
+function LayoutReview() {
+ const [containmentTarget, setContainmentTarget] = useState(null)
+ const [chatOpen, setChatOpen] = useState(true)
+ return (
+
+
+ CodePress · Video editor · Layout fixture
+
+ {containment && (
+
+ )}
+
+
+
+ Conversation
+
+ Keep the conversation beside the edit.
+ Review the opening and tighten the pause.
+
+
+
+
+ {containmentTarget &&
+ createPortal(
+
+ Containment-only second shell, no editor runtime
+ ,
+ containmentTarget,
+ )}
+ setChatOpen(!chatOpen)}>
+ {chatOpen ? 'Hide Chat' : 'Show Chat'}
+
+ >
+ ),
+ headerActions: (
+ <>
+
+ Undo
+
+
+ Redo
+
+
+ Export
+
+ >
+ ),
+ transcriptActions: Transcription controls remain mounted here.
,
+ }}
+ />
+
+
+
+ )
+}
+createRoot(document.getElementById('root')!).render( )