Refine video canvas workspace UX
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 02:26:18 +08:00
parent be5b9aef12
commit f6b9a6f73b
2 changed files with 66 additions and 165 deletions

View File

@ -79,13 +79,11 @@ import {
} from "@/services/canvas";
import {
buildVideoWorkflowCanvasNode,
buildVideoWorkflowStarterTemplateGraph,
getVideoWorkflowNodeDefinition,
getVideoWorkflowQuickConnectOutputs,
getVideoWorkflowSectionNodes,
inferVideoWorkflowTemplateType,
VIDEO_WORKFLOW_LIBRARY_SECTIONS,
VIDEO_WORKFLOW_STARTER_TEMPLATES,
type VideoWorkflowTemplateType,
} from "@/lib/video-workflow/catalog";
import {
@ -428,10 +426,6 @@ const VIDEO_CANVAS_PANEL_BADGE_CLASS =
"border-border/60 bg-background/82 text-foreground/75";
const VIDEO_CANVAS_SUBSECTION_CLASS =
"rounded-2xl border border-border/60 bg-background/72 p-3";
const VIDEO_CANVAS_TEMPLATE_CARD_CLASS =
"rounded-[24px] border border-border/60 bg-background/76 p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/50 hover:bg-background/92 hover:shadow-lg";
const VIDEO_CANVAS_SIDEBAR_SHELL_CLASS =
"h-full w-full overflow-hidden rounded-l-[30px] border border-r-0 border-border/60 bg-background/94 shadow-[0_30px_80px_-34px_rgba(15,23,42,0.42)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/82";
const VIDEO_CANVAS_TOOLBAR_CLASS =
"flex items-center gap-1.5 rounded-[28px] border border-border/60 bg-background/94 px-2.5 py-1.5 shadow-2xl backdrop-blur-xl supports-[backdrop-filter]:bg-background/82";
const VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS =
@ -739,7 +733,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
@ -1376,8 +1370,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
const resolvedPanelWidth = viewportWidth > 0
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const isFloatingSidebarVisible = !isVideoMode && isPanelOpen;
const panelOcclusionRight = isFloatingSidebarVisible ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isFloatingSidebarVisible ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP;
const updateCanvasSearchParams = useCallback(
@ -4476,63 +4471,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
[createCanvasNode, getVideoWorkflowPlacement]
);
const handleInsertVideoWorkflowTemplate = useCallback(
(templateId: string) => {
const upstreamNodeId =
isVideoMode && selectedNodeIds.length === 1 ? selectedNodeIds[0] || null : null;
const starterTemplate =
VIDEO_WORKFLOW_STARTER_TEMPLATES.find((item) => item.id === templateId) ||
VIDEO_WORKFLOW_STARTER_TEMPLATES[0];
const firstNodeType = starterTemplate.nodes[0] || "text-node";
const firstNodeDefinition = getVideoWorkflowNodeDefinition(firstNodeType);
const origin = upstreamNodeId
? getVideoWorkflowPlacement(
firstNodeDefinition?.size || { width: 360, height: 280 }
)
: getVisibleCanvasOrigin();
const { nodes: templateNodes, edges: templateEdges } =
buildVideoWorkflowStarterTemplateGraph(templateId, origin);
const createdIds = templateNodes.map((item) =>
createCanvasNode(item.node, {
connectFromSelection: false,
selectNewNode: false,
})
);
if (upstreamNodeId && createdIds[0] && upstreamNodeId !== createdIds[0]) {
addEdge({
source: upstreamNodeId,
target: createdIds[0],
type: "default",
});
}
templateEdges.forEach((edge) => {
const sourceId = createdIds[edge.sourceIndex];
const targetId = createdIds[edge.targetIndex];
if (!sourceId || !targetId) return;
addEdge({
source: sourceId,
target: targetId,
type: edge.type,
});
});
if (createdIds.length > 0) {
selectNodes(createdIds);
}
},
[
addEdge,
createCanvasNode,
getVisibleCanvasOrigin,
getVideoWorkflowPlacement,
isVideoMode,
selectNodes,
selectedNodeIds,
]
);
const handleCreateSelectedAgentResultBranches = useCallback(async () => {
if (
!selectedVideoWorkflowNode ||
@ -7140,6 +7078,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
occlusionRight={panelOcclusionRight}
workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"}
showEmptyState={!isVideoMode}
emptyStateDescription={
isVideoMode
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
@ -7156,54 +7095,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
) : null}
{isVideoMode && nodes.length === 0 ? (
<div className="pointer-events-none absolute left-4 top-16 z-20 w-[min(620px,calc(100vw-32px))]">
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
<CardHeader className="space-y-2 pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">参考工作流模板</CardTitle>
<CardDescription className="mt-1">
这里按参考 APP 的核心链路预置了脚本成片、图片转视频、视频拆解、角色拆解和局部重绘几条起手流程。
</CardDescription>
</div>
<Badge variant="outline" className={VIDEO_CANVAS_PANEL_BADGE_CLASS}>
第四阶段
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-3 pb-5 md:grid-cols-2">
{VIDEO_WORKFLOW_STARTER_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => handleInsertVideoWorkflowTemplate(template.id)}
className={VIDEO_CANVAS_TEMPLATE_CARD_CLASS}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{template.title}</div>
<Badge
variant="outline"
className={cn(VIDEO_CANVAS_PANEL_BADGE_CLASS, "text-xs")}
>
{template.nodes.length} 步
</Badge>
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{template.description}
</p>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
{template.nodes
.map((type) => getVideoWorkflowNodeDefinition(type)?.label || type)
.join(" -> ")}
</p>
</button>
))}
</CardContent>
</Card>
</div>
) : null}
{isVideoMode &&
selectedVideoWorkflowDefinition &&
(selectedVideoWorkflowQuickConnects.length > 0 || selectedWorkflowIsActionable) ? (
@ -7428,7 +7319,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
{!isVideoMode && !isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
<Button
variant="ghost"
@ -7447,54 +7338,49 @@ export default function CanvasEditorPage({ params }: PageProps) {
)}
{/* 4. Floating Sidebar (Right) */}
<div className={cn(
"absolute z-10 transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}>
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden",
isVideoMode
? VIDEO_CANVAS_SIDEBAR_SHELL_CLASS
: "border-l bg-white dark:bg-zinc-950"
)}
>
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
selectedAgentPresetId={
isVideoMode || !isCanvasAgentModeEnabled ? null : selectedAgentPresetId
}
chatScope="canvas"
agentEnabled={isVideoMode ? false : isCanvasAgentModeEnabled}
workspaceVariant={isVideoMode ? "video" : "canvas"}
{!isVideoMode ? (
<div className={cn(
"absolute z-10 transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}>
<div
className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950"
>
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
selectedAgentPresetId={!isCanvasAgentModeEnabled ? null : selectedAgentPresetId}
chatScope="canvas"
agentEnabled={isCanvasAgentModeEnabled}
workspaceVariant="canvas"
/>
</div>
) : (
// 文件列表
<CanvasFileListPanel
currentId={id}
onClose={() => setPanelMode('chat')}
workspaceType={activeCanvasWorkspaceType}
workspaceLabel={workspaceLabel}
createUrl={createNewCanvasUrl}
buildProjectUrl={buildWorkspaceProjectUrl}
/>
</div>
) : (
// 文件列表
<CanvasFileListPanel
currentId={id}
onClose={() => setPanelMode('chat')}
workspaceType={activeCanvasWorkspaceType}
workspaceLabel={workspaceLabel}
createUrl={createNewCanvasUrl}
buildProjectUrl={buildWorkspaceProjectUrl}
/>
)}
</div>
</div>
)}
</div>
</div>
) : null}
{/* 5. Floating Toolbar (Bottom) */}
<div

View File

@ -44,6 +44,7 @@ interface CanvasWorkspaceProps {
workspaceLabel?: string;
emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video";
showEmptyState?: boolean;
}
export function CanvasWorkspace({
@ -51,6 +52,7 @@ export function CanvasWorkspace({
workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
showEmptyState = true,
}: CanvasWorkspaceProps) {
const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12;
@ -516,7 +518,20 @@ export function CanvasWorkspace({
if (connectionDraft) {
return;
}
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
const shouldPanBlankWorkflowCanvas =
isWorkflowCanvas &&
tool === "select" &&
e.button === 0 &&
!e.altKey &&
!e.shiftKey &&
!isPointOnNodeElement(e.target);
if (
tool === 'pan' ||
e.button === 1 ||
(e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas
) {
setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
e.preventDefault();
@ -531,7 +546,7 @@ export function CanvasWorkspace({
setMarqueeStart(start);
setMarqueeEnd(start);
}
}, [clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
}, [clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool, viewport]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@ -1507,7 +1522,7 @@ export function CanvasWorkspace({
)}
{/* 空状态提示 */}
{nodes.length === 0 && (
{showEmptyState && nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isWorkflowCanvas ? (
<div className="rounded-[30px] border border-border/60 bg-background/90 px-8 py-6 text-center shadow-[0_24px_60px_-34px_rgba(15,23,42,0.35)] backdrop-blur-xl">