Files
ttstd-infinite-canvas/web/src/pages/projects/detail/editor.tsx

580 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
Check,
CircleDot,
Download,
Loader2,
Maximize,
Minimize,
PanelRightClose,
PanelRightOpen,
Redo2,
Sparkles,
Undo2,
X,
} from "lucide-react";
import type { ProjectAsset, ProjectDetail, ShotArtifact } from "@/services/api/projects";
import { linkProjectAsset, listProjectAssets } from "@/services/api/projects";
import { useEditorSlots, type EditorSlotRegistration } from "@/lib/plugins/editor-slot-registry";
import { pluginMayRenderEditorSlot } from "@/lib/plugins/plugin-permission-check";
import { EditorStoreProvider } from "@/components/editor/editor-context";
import { createEditorStore } from "@/stores/editor/editor-store";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeTimelineProject } from "@/lib/timeline/timeline-tracks";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import type { TimelineProject } from "@/types/timeline";
const EDITOR_TIMELINE_KEY = "editor-timeline";
/**
* 为尚未产生本地时间线的项目建立一个可编辑的初始时间线。
* 该时间线只写入当前用户与项目作用域的本地存储,不代表服务端已有成片数据。
*/
function createEmptyEditorTimeline(): TimelineProject {
// 没有真实素材时返回空时间线,避免把不存在的 demo node 当成可剪辑资产。
// 用户添加素材后由编辑器命令创建轨道和片段;空项目不应被伪造为已有成片。
return normalizeTimelineProject({ version: 2, tracks: [], clips: [], durationMs: 0 });
}
/** 插槽堆叠:只渲染通过权限检查的插件贡献;无可用插件时显示明确的缺失能力提示。
* 缺权限插件不渲染,改为一行诊断提示;无贡献时显示空态(停用插件可见)。 */
function SlotStack({ slots, emptyHint }: { slots: EditorSlotRegistration[]; emptyHint: string }) {
const allowed = slots.filter((slot) => pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
const denied = slots.filter((slot) => !pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
if (allowed.length === 0 && denied.length === 0) {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-3 px-4 text-center">
<p className="max-w-md text-sm text-[var(--director-dock-fg)]">{emptyHint}</p>
</div>
);
}
return (
<>
{allowed.map((slot) => (
<div key={slot.id} className="min-h-0 flex-1 overflow-hidden">
{slot.render({ pluginId: slot.pluginId })}
</div>
))}
{denied.map((slot) => {
const verdict = pluginMayRenderEditorSlot(slot.pluginId, slot.slot);
const missing =
verdict.allowed === false && verdict.reason === "missing-permission" ? verdict.missing : null;
return (
<div
key={slot.id}
title={`插件 ${slot.pluginId} 缺少 ${missing ?? "注册"},已按 fail-closed 拒绝渲染`}
className="flex min-h-8 shrink-0 items-center gap-2 bg-[var(--director-danger)]/10 px-3 text-[11px] text-[var(--director-danger)]"
>
<AlertTriangle className="size-3 shrink-0" />
<span className="truncate">
{missing
? `插件 ${slot.pluginId} 缺少 ${missing} 权限,已停用该面板。`
: `插件 ${slot.pluginId} 未注册,已停用该面板。`}
</span>
</div>
);
})}
</>
);
}
/** 面板 Tab 条(Concat 式分类/属性切换):只渲染激活槽,全部槽始终注册。 */
function PanelTabs({
tabs,
active,
onChange,
trailing,
}: {
tabs: { id: string; label: string }[];
active: string;
onChange: (id: string) => void;
trailing?: React.ReactNode;
}) {
return (
<div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-1.5">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`h-7 rounded-md px-2.5 text-xs transition-colors ${
active === tab.id
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{tab.label}
</button>
))}
<div className="flex-1" />
{trailing}
</div>
);
}
/** 顶部工具栏(Concat 主菜单区):项目名 + 全局撤销/重做 + 保存状态 + 导出主按钮。 */
function EditorTopBar({
onExport,
isFullscreen,
onToggleFullscreen,
aiOpen,
onToggleAi,
}: {
onExport: () => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
aiOpen: boolean;
onToggleAi: () => void;
}) {
const { project, history, isDirty, saving, saveError, undo, redo } = useEditorStoreContext();
const canUndo = (history?.undoStack.length ?? 0) > 0;
const canRedo = (history?.redoStack.length ?? 0) > 0;
return (
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
<button
type="button"
aria-label="撤销"
title="撤销(Cmd/Ctrl+Z)"
onClick={undo}
disabled={!canUndo}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-35"
>
<Undo2 className="size-4" />
</button>
<button
type="button"
aria-label="重做"
title="重做(Cmd/Ctrl+Shift+Z)"
onClick={redo}
disabled={!canRedo}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-35"
>
<Redo2 className="size-4" />
</button>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<span className="grid size-8 place-items-center" title={saveError ?? (saving ? "保存中" : isDirty ? "有未保存的更改" : "已保存")}>
{saveError ? (
<AlertTriangle className="size-4 text-[var(--director-danger)]" aria-label="保存失败" />
) : saving ? (
<Loader2 className="size-4 animate-spin text-[var(--director-dock-fg)]" aria-label="保存中" />
) : isDirty ? (
<CircleDot className="size-3.5 text-[var(--director-warning)]" aria-label="未保存" />
) : (
<Check className="size-4 text-[var(--director-success)]" aria-label="已保存" />
)}
</span>
<button
type="button"
aria-label="AI 剪辑助手"
aria-pressed={aiOpen}
title="AI 剪辑助手(编辑意图转时间线指令)"
onClick={onToggleAi}
className={`grid size-8 place-items-center rounded-md transition-colors ${
aiOpen
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
<Sparkles className="size-4" />
</button>
<button
type="button"
aria-label={isFullscreen ? "退出全屏" : "全屏编辑"}
title={isFullscreen ? "退出全屏(Esc)" : "全屏编辑"}
onClick={onToggleFullscreen}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
{isFullscreen ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
</button>
<button
type="button"
aria-label="导出"
title="导出"
onClick={onExport}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] hover:text-[var(--director-dock-fg-strong)]"
>
<Download className="size-4" />
</button>
</div>
</header>
);
}
/** 底部状态栏:时长 / 片段数 / 轨道数 / 快捷键提示。 */
function EditorStatusBar({ projectName }: { projectName: string }) {
const { project } = useEditorStoreContext();
const clipCount = project?.clips.length ?? 0;
const trackCount = project?.tracks.length ?? 0;
const durationMs = project?.durationMs ?? 0;
return (
<footer className="flex h-7 shrink-0 items-center gap-4 border-t border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 text-[10px] text-[var(--director-dock-fg)]">
<span className="truncate text-[var(--director-dock-fg-strong)]">{projectName}</span>
<span className="tabular-nums">时长 {formatTimelineTime(durationMs)}</span>
<span>片段 {clipCount}</span>
<span>轨道 {trackCount}</span>
<div className="flex-1" />
<span className="hidden sm:inline">⌘Z 撤销 · ⇧⌘Z 重做 · 拖拽移动 / 边缘裁剪片段</span>
</footer>
);
}
export default function ProjectEditorView({ detail }: { detail: ProjectDetail }) {
// 插槽注册(全部 8 种始终注册;左/右栏 Tab 决定当前渲染哪个)。
const previewSlots = useEditorSlots("preview-renderer");
const timelineSlots = useEditorSlots("timeline-panel");
const assetSlots = useEditorSlots("asset-ingest");
const transcriptionSlots = useEditorSlots("transcription-provider");
const inspectorSlots = useEditorSlots("inspector");
const subtitleSlots = useEditorSlots("subtitle-tool");
const exportSlots = useEditorSlots("export-renderer");
const aiSlots = useEditorSlots("ai-assistant");
const [leftTab, setLeftTab] = useState("asset");
const [rightTab, setRightTab] = useState("inspector");
const [rightCollapsed, setRightCollapsed] = useState(false);
const [assets, setAssets] = useState<ProjectAsset[]>(detail.assets);
const workbenchRef = useRef<HTMLDivElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// AI 助手浮层由顶栏按钮打开,支持 Esc 和遮罩点击关闭,避免长期遮挡编辑区。
const [aiOpen, setAiOpen] = useState(false);
useEffect(() => {
if (!aiOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setAiOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [aiOpen]);
// 底部时间线高度(px):分隔条拖拽调整(120–480)。
const [timelineH, setTimelineH] = useState(240);
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
const scope = getActiveUserScope();
const projectId = detail.project.id;
const assetOwnerKey = JSON.stringify([scope, projectId]);
const activeAssetOwnerKeyRef = useRef(assetOwnerKey);
const assetRefreshSequenceRef = useRef(0);
activeAssetOwnerKeyRef.current = assetOwnerKey;
const refreshAssets = useCallback(async (): Promise<ProjectAsset[] | null> => {
const requestedOwnerKey = assetOwnerKey;
const requestSequence = assetRefreshSequenceRef.current + 1;
assetRefreshSequenceRef.current = requestSequence;
try {
const list = (await listProjectAssets(projectId)).assets;
// 项目、账号或刷新序号变化都说明当前响应已经过期;旧响应不得覆盖新上下文的素材快照。
if (
activeAssetOwnerKeyRef.current !== requestedOwnerKey ||
assetRefreshSequenceRef.current !== requestSequence
) {
return null;
}
setAssets(list);
return list;
} catch (error) {
// 过期请求的错误不属于当前编辑器上下文,也不应产生误导性告警。
if (activeAssetOwnerKeyRef.current !== requestedOwnerKey) return null;
// 资产列表属于读展示路径:保留当前快照,避免网络抖动让编辑器清空素材。
// 但不能把快照当成同步成功;记录项目上下文,便于定位并在下次进入时重试。
console.warn("刷新项目素材列表失败,已保留当前快照", { projectId, scope, error });
return null;
}
}, [assetOwnerKey, projectId, scope]);
useEffect(() => {
const onFsChange = () => setIsFullscreen(document.fullscreenElement === workbenchRef.current);
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
const toggleFullscreen = () => {
const operation = document.fullscreenElement
? document.exitFullscreen()
: workbenchRef.current?.requestFullscreen();
void operation?.catch((error) => {
console.warn("切换编辑器全屏状态失败", { projectId, error });
});
};
const detailAssetsRef = useRef(detail.assets);
detailAssetsRef.current = detail.assets;
// 挂载、切换项目或切换账号时先恢复详情快照,再以服务端列表校准。
// detail.assets 只用于切换瞬间占位;refreshAssets 内部负责丢弃过期和乱序响应。
useEffect(() => {
setAssets(detailAssetsRef.current);
void refreshAssets();
return () => {
// 使当前 owner 下仍在途的请求失效,避免组件卸载后提交 state。
assetRefreshSequenceRef.current += 1;
};
}, [assetOwnerKey, refreshAssets]);
const store = useMemo(
() =>
createEditorStore({
saveTimeline: async (project: TimelineProject) => {
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
},
}),
[projectId, scope],
);
// 进入编辑器时只加载当前用户和项目作用域的本地时间线;没有真实数据时保持空时间线,禁止注入虚构片段。
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const storage = localForageStorageForScope(scope);
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
let loaded = createEmptyEditorTimeline();
if (raw) {
try {
loaded = normalizeTimelineProject(JSON.parse(raw));
} catch (error) {
// 本地历史数据属于读展示路径:坏记录不能阻塞编辑器启动,
// 但必须记录项目和存储键,后续可按键清理或迁移,而不是把坏数据伪装成有效时间线。
console.warn("读取编辑器本地时间线失败,已使用空时间线", {
projectId,
storageKey: `${EDITOR_TIMELINE_KEY}:${projectId}`,
error,
});
}
}
if (!cancelled) store.getState().load(loaded);
} catch (error) {
// IndexedDB/localforage 不可用时不写入空时间线,避免覆盖可能仍可恢复的本地数据。
console.error("读取编辑器本地时间线存储失败,保留编辑器初始状态", { projectId, error });
}
})();
return () => {
cancelled = true;
// 卸载前冲刷尚未落盘的改动(如切页发生在防抖窗口内),避免最后几次操作丢失。
// flushSave 失败时 editor-store 会保留 isDirty;这里必须留下错误证据,不能假装已保存。
const flushSave = store.getState().flushSave;
if (flushSave) {
void flushSave().catch((error) => {
console.error("编辑器卸载前保存时间线失败,待下次操作重试", { projectId, error });
});
}
};
}, [scope, projectId, store]);
// 画布产物自动同步:detail.shotArtifacts 中当前采用(storyboard/action_board/
// 分镜/导出成片,且 ready + 有资源)尚未作为素材进本项目时,自动
// linkProjectAsset(source=canvas) 并入素材库 —— 剪辑器“素材选项”直接可用画布产物。
// 后端幂等(已链接直接返回);单个失败不阻塞其余资源,但会记录告警,
// 下次进入编辑器时再次尝试补同步。
// 该集合只表示“当前用户、当前项目会话已处理过”,切换账号或项目时必须清空,避免跨作用域复用 resource ID。
const syncedCanvasResourcesRef = useRef<Set<string>>(new Set());
const syncedAssetOwnerKeyRef = useRef(assetOwnerKey);
useEffect(() => {
if (syncedAssetOwnerKeyRef.current === assetOwnerKey) return;
syncedAssetOwnerKeyRef.current = assetOwnerKey;
syncedCanvasResourcesRef.current.clear();
}, [assetOwnerKey]);
const canvasSyncKeys = detail.shotArtifacts
.filter(
(a): a is ShotArtifact & { resourceId: string } =>
a.selected &&
a.status === "ready" &&
!!a.resourceId &&
["storyboard", "action_board", "start_frame", "end_frame", "video", "delivery"].includes(a.type),
)
.map((a) => `resource:${a.resourceId}`)
.join(",");
useEffect(() => {
if (!canvasSyncKeys) return;
const keys = canvasSyncKeys.split(",");
// 已同步过的(本会话 ref)或已存在于项目素材的(storageKey 命中)均跳过 ——
// storageKey 与 assetFromUploadedResource 合成格式一致(resource:<id>)。
const existing = new Set(assets.map((a) => a.storageKey));
const pending = keys.filter((key) => !syncedCanvasResourcesRef.current.has(key) && !existing.has(key));
if (!pending.length) return;
let cancelled = false;
void (async () => {
for (const key of pending) {
if (cancelled) break;
const resourceId = key.slice("resource:".length);
try {
await linkProjectAsset(projectId, { assetId: resourceId, category: "material", source: "canvas" });
// 请求返回时组件可能已经切换项目;旧请求不能把结果记入新项目的会话集合。
if (cancelled) break;
syncedCanvasResourcesRef.current.add(key);
} catch (error) {
// 单个资源同步失败不阻塞其他资源;失败项不加入已完成集合,
// 这样下次进入编辑器仍会重试,而不是把未同步伪装成成功。
console.warn("画布产物同步到项目素材失败", { projectId, resourceId, error });
}
}
if (!cancelled) {
// 统一经过带 owner 与请求序号校验的刷新入口,防止同步后的旧请求覆盖新项目快照。
await refreshAssets();
}
})();
return () => {
cancelled = true;
};
}, [assets, canvasSyncKeys, projectId, refreshAssets]);
return (
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>
<div ref={workbenchRef} className="editor-workbench relative flex h-full min-h-0 flex-col bg-[var(--director-workspace-bg)]">
<EditorTopBar
onExport={() => setRightTab("export")}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
aiOpen={aiOpen}
onToggleAi={() => setAiOpen((v) => !v)}
/>
{/* AI 助手浮层:常驻挂载、随开关显隐,关闭再开保留会话与输入(状态在 SlotStack 内)。 */}
<div className={aiOpen ? "contents" : "hidden"}>
<button
type="button"
aria-label="关闭 AI 助手"
tabIndex={-1}
className="fixed inset-0 z-30 cursor-default bg-transparent"
onClick={() => setAiOpen(false)}
/>
<div className="absolute right-2 top-14 z-40 flex h-[640px] max-h-[calc(100%-3.75rem)] w-[460px] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface)] shadow-[0_12px_40px_rgba(0,0,0,0.5)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] px-3">
<Sparkles className="size-4 text-[var(--director-dock-fg)]/80" />
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 剪辑助手</span>
<span className="hidden truncate text-[10px] text-[var(--director-dock-fg)]/45 sm:inline">
编辑意图 → 时间线指令(宿主校验)
</span>
<div className="flex-1" />
<button
type="button"
aria-label="关闭"
title="关闭(Esc)"
onClick={() => setAiOpen(false)}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<SlotStack slots={aiSlots} emptyHint="AI 助手插件未加载。" />
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2">
{/* 上区:左栏 + 预览 + 右栏 */}
<div className="flex min-h-0 flex-1 gap-2">
{/* 左栏:素材 / 转写(Tab 化) */}
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<PanelTabs
tabs={[
{ id: "asset", label: "素材" },
{ id: "transcription", label: "转写" },
]}
active={leftTab}
onChange={setLeftTab}
/>
{leftTab === "asset" ? (
<SlotStack slots={assetSlots} emptyHint="素材库插件未加载。" />
) : (
<SlotStack slots={transcriptionSlots} emptyHint="转写插件未加载。" />
)}
</aside>
{/* 中栏:预览(占满剩余高度) */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<SlotStack slots={previewSlots} emptyHint="预览插件未加载。" />
</main>
{/* 右栏:检查 / 字幕 / 导出(Tab 化,可折叠为窄条;AI 助手已移至顶栏浮层) */}
{rightCollapsed ? (
<aside className="flex h-full w-9 shrink-0 flex-col items-center overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<button
type="button"
aria-label="展开检查器"
title="展开检查器"
onClick={() => setRightCollapsed(false)}
className="mt-1.5 grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<PanelRightOpen className="size-4" />
</button>
</aside>
) : (
<aside className="flex h-full w-80 shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<PanelTabs
tabs={[
{ id: "inspector", label: "检查" },
{ id: "subtitle", label: "字幕" },
{ id: "export", label: "导出" },
]}
active={rightTab}
onChange={setRightTab}
trailing={
<button
type="button"
aria-label="折叠检查器"
title="折叠检查器"
onClick={() => setRightCollapsed(true)}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<PanelRightClose className="size-3.5" />
</button>
}
/>
{rightTab === "inspector" ? (
<SlotStack slots={inspectorSlots} emptyHint="检查器插件未加载。" />
) : rightTab === "subtitle" ? (
<SlotStack slots={subtitleSlots} emptyHint="字幕工具插件未加载。" />
) : (
<SlotStack slots={exportSlots} emptyHint="导出插件未加载。" />
)}
</aside>
)}
</div>
{/* 预览/时间线拖拽分隔条(pointer capture,data-canvas-no-zoom 防画布缩放) */}
<div
className="group relative z-10 -my-1 flex h-3 shrink-0 cursor-row-resize touch-none items-center justify-center"
role="separator"
aria-orientation="horizontal"
aria-label="调整时间线高度"
data-canvas-no-zoom
onPointerDown={(e) => {
splitterRef.current = { startY: e.clientY, startH: timelineH };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const drag = splitterRef.current;
if (!drag) return;
const next = drag.startH - (e.clientY - drag.startY);
setTimelineH(Math.min(480, Math.max(120, next)));
}}
onPointerUp={() => {
splitterRef.current = null;
}}
onPointerCancel={() => {
splitterRef.current = null;
}}
>
<div className="h-1 w-10 rounded-full bg-[var(--director-sequencer-border)] transition-colors group-hover:bg-[var(--director-dock-fg-strong)]/60" />
</div>
{/* 时间线:横跨编辑面板全宽(左栏到右栏) */}
<div className="flex h-full shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]" style={{ height: `${timelineH}px` }}>
<SlotStack slots={timelineSlots} emptyHint="时间线面板插件未加载。" />
</div>
</div>
<EditorStatusBar projectName={detail.project.name} />
</div>
</EditorStoreProvider>
);
}