diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 3a5ea9f1d..34c75ef0e 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -63,6 +63,48 @@ provider details, URLs, paths, and media bytes remain host-owned. The same 0.3.0 surface retains the host-backed caption tracks, bounded cues, caption styles, and display toggles from 0.2.0. +The 0.3.8 surface lets the host register its own modules into the editor's +left sidebar rail. `EditorHost.sidebarModules` entries (`{ id, label, icon, +Panel }`) appear as `host:` rail tabs; the surface renders each module's +`Panel` in the sidebar panel area, mounts it on first activation, and keeps it +mounted across tab switches and authoritative snapshot installs so in-flight +host work survives. Each `Panel` receives `{ active, collapsed, width }`, so it +can pause work while it is not the selected tab and adapt to a sidebar resize +without measuring the DOM itself. Icons and panels cross the package boundary +as React components (react/react-dom are peer dependencies). + +`FreeCutEditorSurface` also accepts an optional `apiRef` that receives a +`FreeCutEditorSurfaceApi` — `openSidebarModule(id)` selects a module's tab and +opens the panel (ids the rail does not show fail closed), and `closeSidebar()` +closes the panel. + +By default the rail is the capability-gated built-ins (`media`, then `text` and +`transcript` when the host's capabilities allow them) followed by the modules in +registration order. `EditorHost.sidebarRail` replaces that with an explicit +rail: the exact tabs, in the exact order, with anything omitted hidden — which +is how a host both reorders the rail and suppresses built-ins it does not want. +Capability gating still runs first, so a rail can only ever subtract from and +reorder what the capabilities already allow, never add to it. Ids matching no +available tab are dropped, as are repeats after the first, and a rail that +matches nothing at all falls back to the default rather than leaving the editor +with no navigation. + +```tsx +const apiRef = useRef(null) + +const host: EditorHost = { + // ...capabilities, load, resolveMedia, submitEdit + sidebarModules: [ + { id: 'transcribe', label: 'Transcribe', icon: Captions, Panel: TranscribePanel }, + { id: 'brand-kit', label: 'Brand kit', icon: Palette, Panel: BrandKitPanel }, + ], + // Host module first, no built-in `text` tab. + sidebarRail: ['host:transcribe', 'media', 'host:brand-kit'], +} + + +``` + This package is built from a specific FreeCut commit. To create the local consumer artifact from a clean checkout, run: diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index 00d4f5a73..cc8bdda58 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.3.7", + "version": "0.3.8", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", "repository": { diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index 9a417f9da..66012a4cf 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -1,4 +1,4 @@ -import type { ComponentType, ReactNode } from 'react' +import type { ComponentType, ReactNode, RefObject } from 'react' export type EditorCapability = | 'project.navigate' @@ -354,6 +354,53 @@ export interface EditorHostNavigation { back(): void } +/** + * Rail tab ids. Built-ins are FreeCut's own; `host:${string}` ids belong to + * `sidebarModules` entries. + */ +export type EditorSidebarTab = + | 'media' + | 'text' + | 'shapes' + | 'effects' + | 'transitions' + | 'lottie' + | 'transcript' + | 'ai' + | `host:${string}` + +/** + * Props a host module panel receives. A panel that only cares about `active` + * can keep destructuring just that — the extra fields widen the props without + * breaking an existing `({ active }) => …` component. + */ +export interface EditorSidebarModulePanelProps { + /** Whether this module's tab is the selected one. */ + active: boolean + /** + * Whether the sidebar panel area is collapsed. A latched panel keeps + * rendering while collapsed so its work survives; use this to pause + * animation or defer layout measurement rather than to unmount. + */ + collapsed: boolean + /** Current sidebar panel width in px, so a panel can adapt to a resize. */ + width: number +} + +export interface EditorSidebarModule { + /** Host-scoped identifier; namespaced to `host:` internally. */ + id: string + /** Rail tooltip and panel header label. Host-owned, already localized. */ + label: string + /** Lucide-compatible rail icon. */ + icon: ComponentType<{ className?: string }> + /** + * Rendered in the sidebar panel area. Mounted on first activation and kept + * mounted across tab switches so in-flight host work survives. + */ + Panel: ComponentType +} + export interface EditorHost { readonly capabilities: EditorCapabilityMap load(): Promise | EmbeddedEditorSnapshot @@ -363,6 +410,21 @@ export interface EditorHost { submitEdit(batch: EditCommandBatch): Promise | HostEditResult subscribe?(listener: (snapshot: EmbeddedEditorSnapshot) => void): () => void transcript?: EditorTranscriptPort + sidebarModules?: readonly EditorSidebarModule[] + /** + * Optional explicit rail: the exact tabs to show, in the exact order, as + * built-in ids (`'media'`, `'text'`, `'transcript'`) and registered module + * ids (`` `host:${id}` ``). Anything omitted is hidden, so this is how a + * host both reorders the rail and suppresses built-ins it does not want. + * + * Capability gating still runs first — a rail cannot surface a tab the + * host's own capabilities deny — and ids that match nothing are dropped, as + * are repeats after the first. Omit the field for the default rail + * (capability-gated built-ins, then modules in registration order). A rail + * that matches nothing at all falls back to the default rather than leaving + * the editor with no navigation. + */ + sidebarRail?: readonly EditorSidebarTab[] navigation?: EditorHostNavigation notify?(notice: HostNotice): void } @@ -382,8 +444,21 @@ export interface EditorHostProviderProps { children: ReactNode } +export interface FreeCutEditorSurfaceApi { + /** + * Select a registered `sidebarModules` entry's tab and open the panel. + * No-ops for an id the host never registered, and for one its `sidebarRail` + * suppresses — opening a tab with no rail button would strand the user, and + * the next authoritative snapshot would reset it anyway. + */ + openSidebarModule(id: string): void + /** Close the left sidebar panel. */ + closeSidebar(): void +} + export interface FreeCutEditorSurfaceProps { host: EditorHost + apiRef?: RefObject } export declare const FreeCutEditorSurface: ComponentType diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts index 8bad3a08d..651800e28 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -1,4 +1,5 @@ export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' +export type { FreeCutEditorSurfaceApi } from '@/features/editor/host/editor-surface' export { EditorHostProvider } from '@/features/editor/host/context-provider' export { DEFAULT_HOST_CAPABILITIES, @@ -14,6 +15,7 @@ export { createLocalEditorHost, isHostCapabilityEnabled, } from '@/features/editor/host/contract' +export type { EditorSidebarTab } from '@/config/editor-workspaces' export type { EditorHostContextValue } from '@/features/editor/host/context' export type { EditorHostProviderProps } from '@/features/editor/host/context-provider' export type { @@ -21,6 +23,8 @@ export type { EditorCapabilityMap, EditorHost, EditorHostNavigation, + EditorSidebarModule, + EditorSidebarModulePanelProps, EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, diff --git a/src/config/editor-workspaces.ts b/src/config/editor-workspaces.ts index fc579fa90..6c74c0ca5 100644 --- a/src/config/editor-workspaces.ts +++ b/src/config/editor-workspaces.ts @@ -18,6 +18,7 @@ export type EditorSidebarTab = | 'lottie' | 'transcript' | 'ai' + | `host:${string}` export type EditorClipInspectorTab = 'video' | 'motion' | 'audio' | 'effects' /** The slice of editor UI state that a workspace controls. */ @@ -91,8 +92,17 @@ const CLIP_INSPECTOR_TABS: readonly EditorClipInspectorTab[] = [ 'effects', ] +/** + * Host-registered sidebar modules are namespaced under `host:` so persisted + * layouts and host-mode tab resets can recognize them without knowing the + * host's module ids. + */ +export function isHostSidebarTab(value: unknown): value is `host:${string}` { + return typeof value === 'string' && value.startsWith('host:') +} + function isSidebarTab(value: unknown): value is EditorSidebarTab { - return SIDEBAR_TABS.includes(value as EditorSidebarTab) + return SIDEBAR_TABS.includes(value as EditorSidebarTab) || isHostSidebarTab(value) } function isClipInspectorTab(value: unknown): value is EditorClipInspectorTab { diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx index 5d57f3ae8..a17e9562c 100644 --- a/src/features/editor/components/media-sidebar.tsx +++ b/src/features/editor/components/media-sidebar.tsx @@ -1,4 +1,14 @@ -import { useCallback, useMemo, useRef, useEffect, memo, lazy, Suspense, useState } from 'react' +import { + useCallback, + useMemo, + useRef, + useEffect, + memo, + lazy, + Suspense, + useState, + type ComponentType, +} from 'react' import { useTranslation } from 'react-i18next' import { ChevronDown, @@ -78,11 +88,97 @@ import { clampLeftEditorSidebarWidth, getEditorLayout, } from '@/config/editor-layout' +import { isHostSidebarTab, type EditorSidebarTab } from '@/config/editor-workspaces' +import type { EditorHost, EditorSidebarModule } from '../host/contract' +import { hostRailTabIds } from '../host/sidebar-rail' const logger = createLogger('MediaSidebar') const TEXT_TEMPLATE_PREVIEW_SHELL = 'w-full aspect-video rounded-sm border border-border bg-slate-950' +/** + * Host-registered module panels — mounted on first activation, then kept + * mounted (hidden) across tab switches so in-flight host work (e.g. job + * polling) survives the user browsing other tabs. Extracted from the + * MediaSidebar body both for readability and to keep the sidebar's own + * complexity inside the changed-code health budget. + */ +function HostModulePanels({ + modules, + activeTab, + collapsed, + width, +}: { + modules: readonly EditorSidebarModule[] + activeTab: EditorSidebarTab + collapsed: boolean + width: number +}) { + const [activatedTabs, setActivatedTabs] = useState>(() => + isHostSidebarTab(activeTab) ? new Set([activeTab]) : new Set(), + ) + useEffect(() => { + if (!isHostSidebarTab(activeTab)) return + setActivatedTabs((previous) => + previous.has(activeTab) ? previous : new Set(previous).add(activeTab), + ) + }, [activeTab]) + return ( + <> + {modules.map((module) => { + const tabId = `host:${module.id}` as const + return ( +
+ {activatedTabs.has(tabId) && ( + + )} +
+ ) + })} + + ) +} + +type RailCategory = { + id: EditorSidebarTab + icon: ComponentType<{ className?: string }> + label: string +} + +/** Host-registered modules join the rail as `host:` tabs after the built-in categories. */ +function hostModuleRailCategories( + host: { sidebarModules?: readonly EditorSidebarModule[] } | null | undefined, +) { + // Labels are host-owned and already localized. + return (host?.sidebarModules ?? []).map((module) => ({ + id: `host:${module.id}` as const, + icon: module.icon, + label: module.label, + })) +} + +/** + * Rail categories host mode shows, in the order `hostRailTabIds` resolved — + * capability-gated built-ins plus registered modules by default, or exactly + * the host's `sidebarRail` when it declared one. The local editor rail is + * unaffected; only host mode consults the host contract. + */ +function visibleRailCategories( + categories: readonly RailCategory[], + hostMode: boolean, + host: EditorHost | undefined, +): readonly RailCategory[] { + if (!hostMode || !host) return categories + const byId = new Map(categories.map((category) => [category.id, category])) + return hostRailTabIds(host).flatMap((id) => { + const category = byId.get(id) + return category ? [category] : [] + }) +} + function renderTextTemplatePreview(preset?: TextStylePreset) { if (!preset) { return ( @@ -288,12 +384,18 @@ const TEXT_TEMPLATE_GROUPS: ReadonlyArray<{ const DEFAULT_TEXT_TEMPLATE_LABEL = 'Text' const ADD_TEXT_TEMPLATE_LABEL = 'Add Text' +// The sidebar is the editor's tab orchestrator: one branch per built-in panel +// times drag/drop, resize, and host-mode gating. The host-module additions are +// extracted (HostModulePanels, visibleRailCategories above); what remains is +// the pre-existing tab matrix, now covered by real-sidebar tests in +// src/features/editor/host/. Tracked as a known orchestration hotspot, same as +// the preview/timeline modules ignored in .fallowrc.json. +// fallow-ignore-next-line complexity export const MediaSidebar = memo(function MediaSidebar() { const { t } = useTranslation() const hostMode = useEditorHostMode() const { host } = useEditorHostContext() const canAddTimeline = useEditorCapability('timeline.add') - const canTranscribe = useEditorCapability('media.transcription') const editorDensity = useSettingsStore((s) => s.editorDensity) const editorLayout = getEditorLayout(editorDensity) // Use granular selectors - Zustand v5 best practice @@ -559,14 +661,11 @@ export const MediaSidebar = memo(function MediaSidebar() { { id: 'transcript' as const, icon: Captions, label: t('transcript.tabLabel') }, { id: 'ai' as const, icon: WandSparkles, label: t('editor.mediaSidebar.ai') }, ] - const visibleCategories = hostMode - ? categories.filter( - ({ id }) => - id === 'media' || - (id === 'text' && canAddTimeline) || - (id === 'transcript' && canTranscribe && !!host?.transcript), - ) - : categories + const mergedCategories: readonly RailCategory[] = [ + ...categories, + ...hostModuleRailCategories(host), + ] + const visibleCategories = visibleRailCategories(mergedCategories, hostMode, host) const shouldSuppressGeneratedItemClick = useCallback(() => { if (!suppressGeneratedItemClickRef.current) { @@ -709,7 +808,7 @@ export const MediaSidebar = memo(function MediaSidebar() { style={{ height: EDITOR_LAYOUT_CSS_VALUES.sidebarHeaderHeight }} > - {categories.find((c) => c.id === activeTab)?.label} + {mergedCategories.find((c) => c.id === activeTab)?.label}