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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/freecut-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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<FreeCutEditorSurfaceApi>(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'],
}

<FreeCutEditorSurface host={host} apiRef={apiRef} />
```

This package is built from a specific FreeCut commit. To create the local
consumer artifact from a clean checkout, run:

Expand Down
2 changes: 1 addition & 1 deletion packages/freecut-editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantfive/freecut-editor-surface",
"version": "0.3.7",
"version": "0.3.8",
"description": "The host-backed FreeCut browser editor surface.",
"license": "MIT",
"repository": {
Expand Down
77 changes: 76 additions & 1 deletion packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ComponentType, ReactNode } from 'react'
import type { ComponentType, ReactNode, RefObject } from 'react'

export type EditorCapability =
| 'project.navigate'
Expand Down Expand Up @@ -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:<id>` 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<EditorSidebarModulePanelProps>
}

export interface EditorHost {
readonly capabilities: EditorCapabilityMap
load(): Promise<EmbeddedEditorSnapshot> | EmbeddedEditorSnapshot
Expand All @@ -363,6 +410,21 @@ export interface EditorHost {
submitEdit(batch: EditCommandBatch): Promise<HostEditResult> | 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
}
Expand All @@ -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<FreeCutEditorSurfaceApi | null>
}

export declare const FreeCutEditorSurface: ComponentType<FreeCutEditorSurfaceProps>
Expand Down
4 changes: 4 additions & 0 deletions packages/freecut-editor/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,13 +15,16 @@ 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 {
EditorCapability,
EditorCapabilityMap,
EditorHost,
EditorHostNavigation,
EditorSidebarModule,
EditorSidebarModulePanelProps,
EmbeddedEditorAsset,
EmbeddedEditorProject,
EmbeddedEditorSnapshot,
Expand Down
12 changes: 11 additions & 1 deletion src/config/editor-workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
128 changes: 117 additions & 11 deletions src/features/editor/components/media-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ReadonlySet<string>>(() =>
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 (
<div
key={tabId}
className={`min-h-0 flex-1 overflow-hidden ${activeTab === tabId ? 'block' : 'hidden'}`}
>
{activatedTabs.has(tabId) && (
<module.Panel active={activeTab === tabId} collapsed={collapsed} width={width} />
)}
</div>
)
})}
</>
)
}

type RailCategory = {
id: EditorSidebarTab
icon: ComponentType<{ className?: string }>
label: string
}

/** Host-registered modules join the rail as `host:<id>` 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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -709,7 +808,7 @@ export const MediaSidebar = memo(function MediaSidebar() {
style={{ height: EDITOR_LAYOUT_CSS_VALUES.sidebarHeaderHeight }}
>
<span className="text-sm font-medium text-foreground">
{categories.find((c) => c.id === activeTab)?.label}
{mergedCategories.find((c) => c.id === activeTab)?.label}
</span>
<Button
variant="ghost"
Expand Down Expand Up @@ -1192,6 +1291,13 @@ export const MediaSidebar = memo(function MediaSidebar() {
</Suspense>
)}
</div>

<HostModulePanels
modules={host?.sidebarModules ?? []}
activeTab={activeTab}
collapsed={!leftSidebarOpen}
width={sidebarWidth}
/>
</>
</div>
{/* Resize Handle */}
Expand Down
Loading
Loading