From 8e7b12f68066cbdb86c24dfe174a33440cbc3ef4 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Tue, 8 Sep 2026 13:21:41 -0700 Subject: [PATCH 01/11] feat(editor): independent library and editor columns with calmer chrome --- DESIGN.md | 14 + PRODUCT.md | 6 +- package.json | 1 + packages/freecut-editor/README.md | 20 ++ packages/freecut-editor/src/index.d.ts | 14 + packages/freecut-editor/src/index.ts | 4 + playwright.layout-refresh.config.ts | 15 + src/config/hotkeys-dom-guard.test.ts | 12 + src/config/hotkeys.ts | 1 + .../editor-workspace-shell.test.tsx | 122 ++++++++ .../components/editor-workspace-shell.tsx | 261 +++++++++++++++++ src/features/editor/components/editor.tsx | 272 ++++++++++-------- .../editor/components/media-sidebar.tsx | 268 ++++++++++++----- src/features/editor/components/toolbar.tsx | 248 +++++++++------- src/features/editor/host/editor-surface.tsx | 10 +- .../editor/host/transcript-editor.test.tsx | 2 +- .../editor/host/transcript-editor.tsx | 10 +- src/i18n/locales/partials/en/editor.json | 25 ++ tests/browser/layout-refresh.html | 1 + tests/browser/layout-refresh.spec.ts | 98 +++++++ tests/browser/layout-refresh.tsx | 214 ++++++++++++++ 21 files changed, 1318 insertions(+), 300 deletions(-) create mode 100644 playwright.layout-refresh.config.ts create mode 100644 src/features/editor/components/editor-workspace-shell.test.tsx create mode 100644 src/features/editor/components/editor-workspace-shell.tsx create mode 100644 tests/browser/layout-refresh.html create mode 100644 tests/browser/layout-refresh.spec.ts create mode 100644 tests/browser/layout-refresh.tsx 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/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index 9b017f483..edae72804 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -501,7 +501,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 } @@ -532,3 +544,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 30133f3ab..b7ced84a9 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -56,3 +56,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/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..e9a8953d8 --- /dev/null +++ b/src/features/editor/components/editor-workspace-shell.test.tsx @@ -0,0 +1,122 @@ +// @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, cancel, actionMounts, actionUnmounts, selection } = vi.hoisted(() => ({ + pause: 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/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, + }) + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.hideEditor' })) + expect(cancel).toHaveBeenCalledOnce() + expect(pause).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('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..7b4d9c8cc --- /dev/null +++ b/src/features/editor/components/editor-workspace-shell.tsx @@ -0,0 +1,261 @@ +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 { 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 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) + 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() + } + setEditorVisible(!editorVisible) + } + const closeSettings = () => { + setSettingsOpen(false) + settingsTrigger.current?.focus() + } + return ( +
+ +
+
+ { + 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.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 ( + + ) +} + 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 */} -
- -
+ {/* Header row - aligned with content panel header */} +
+ +
- {/* Category Icons */} -
- {visibleCategories.map(({ id, icon: Icon, label }) => ( - - ))} -
-
- + 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 */} {/* 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 && ( + + )} + + ) +} + 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 ( -
+
- - - - - {/* Utility */} - - - - - +
+ + {t('editor.refresh.helpSettings')} + +
+ {/* Socials */} + + - + + + {/* Utility */} + + + + + - {/* Actions */} - +
+ {headerActions} + {/* Actions */} + {onOpenRenderQueue && (