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"; } from "@/services/canvas";
import { import {
buildVideoWorkflowCanvasNode, buildVideoWorkflowCanvasNode,
buildVideoWorkflowStarterTemplateGraph,
getVideoWorkflowNodeDefinition, getVideoWorkflowNodeDefinition,
getVideoWorkflowQuickConnectOutputs, getVideoWorkflowQuickConnectOutputs,
getVideoWorkflowSectionNodes, getVideoWorkflowSectionNodes,
inferVideoWorkflowTemplateType, inferVideoWorkflowTemplateType,
VIDEO_WORKFLOW_LIBRARY_SECTIONS, VIDEO_WORKFLOW_LIBRARY_SECTIONS,
VIDEO_WORKFLOW_STARTER_TEMPLATES,
type VideoWorkflowTemplateType, type VideoWorkflowTemplateType,
} from "@/lib/video-workflow/catalog"; } from "@/lib/video-workflow/catalog";
import { import {
@ -428,10 +426,6 @@ const VIDEO_CANVAS_PANEL_BADGE_CLASS =
"border-border/60 bg-background/82 text-foreground/75"; "border-border/60 bg-background/82 text-foreground/75";
const VIDEO_CANVAS_SUBSECTION_CLASS = const VIDEO_CANVAS_SUBSECTION_CLASS =
"rounded-2xl border border-border/60 bg-background/72 p-3"; "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 = 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"; "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 = const VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS =
@ -739,7 +733,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null); const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(true); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat'); const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
@ -1376,8 +1370,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
const resolvedPanelWidth = viewportWidth > 0 const resolvedPanelWidth = viewportWidth > 0
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0)) ? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH; : FLOATING_PANEL_WIDTH;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0; const isFloatingSidebarVisible = !isVideoMode && isPanelOpen;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0; const panelOcclusionRight = isFloatingSidebarVisible ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isFloatingSidebarVisible ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP; const compactControlsLeft = FLOATING_UI_GAP;
const updateCanvasSearchParams = useCallback( const updateCanvasSearchParams = useCallback(
@ -4476,63 +4471,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
[createCanvasNode, getVideoWorkflowPlacement] [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 () => { const handleCreateSelectedAgentResultBranches = useCallback(async () => {
if ( if (
!selectedVideoWorkflowNode || !selectedVideoWorkflowNode ||
@ -7140,6 +7078,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
occlusionRight={panelOcclusionRight} occlusionRight={panelOcclusionRight}
workspaceLabel={workspaceLabel} workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"} workspaceVariant={isVideoMode ? "video" : "canvas"}
showEmptyState={!isVideoMode}
emptyStateDescription={ emptyStateDescription={
isVideoMode isVideoMode
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成" ? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
@ -7156,54 +7095,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div> </div>
) : null} ) : 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 && {isVideoMode &&
selectedVideoWorkflowDefinition && selectedVideoWorkflowDefinition &&
(selectedVideoWorkflowQuickConnects.length > 0 || selectedWorkflowIsActionable) ? ( (selectedVideoWorkflowQuickConnects.length > 0 || selectedWorkflowIsActionable) ? (
@ -7428,7 +7319,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
{agentDisabledNotice} {agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */} {/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && ( {!isVideoMode && !isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}> <div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
<Button <Button
variant="ghost" variant="ghost"
@ -7447,54 +7338,49 @@ export default function CanvasEditorPage({ params }: PageProps) {
)} )}
{/* 4. Floating Sidebar (Right) */} {/* 4. Floating Sidebar (Right) */}
<div className={cn( {!isVideoMode ? (
"absolute z-10 transition-all duration-300 ease-in-out", <div className={cn(
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none" "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={{ data-canvas-right-panel="true"
top: DOCKED_PANEL_INSET, style={{
right: DOCKED_PANEL_INSET, top: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET, right: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH, bottom: DOCKED_PANEL_INSET,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`, width: FLOATING_PANEL_WIDTH,
}}> maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
<div }}>
className={cn( <div
"flex h-full w-full flex-col overflow-hidden", className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950"
isVideoMode >
? VIDEO_CANVAS_SIDEBAR_SHELL_CLASS {panelMode === 'chat' ? (
: "border-l bg-white dark:bg-zinc-950" // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
)} <div className="flex-1 overflow-hidden" key={`chat-${id}`}>
> <CanvasChatPanel
{panelMode === 'chat' ? ( chatId={`canvas-${id}`}
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 onClose={() => setIsPanelOpen(false)}
<div className="flex-1 overflow-hidden" key={`chat-${id}`}> onOpenFileList={() => setPanelMode('fileList')}
<CanvasChatPanel selectedAgentPresetId={!isCanvasAgentModeEnabled ? null : selectedAgentPresetId}
chatId={`canvas-${id}`} chatScope="canvas"
onClose={() => setIsPanelOpen(false)} agentEnabled={isCanvasAgentModeEnabled}
onOpenFileList={() => setPanelMode('fileList')} workspaceVariant="canvas"
selectedAgentPresetId={ />
isVideoMode || !isCanvasAgentModeEnabled ? null : selectedAgentPresetId </div>
} ) : (
chatScope="canvas" // 文件列表
agentEnabled={isVideoMode ? false : isCanvasAgentModeEnabled} <CanvasFileListPanel
workspaceVariant={isVideoMode ? "video" : "canvas"} currentId={id}
onClose={() => setPanelMode('chat')}
workspaceType={activeCanvasWorkspaceType}
workspaceLabel={workspaceLabel}
createUrl={createNewCanvasUrl}
buildProjectUrl={buildWorkspaceProjectUrl}
/> />
</div> )}
) : ( </div>
// 文件列表 </div>
<CanvasFileListPanel ) : null}
currentId={id}
onClose={() => setPanelMode('chat')}
workspaceType={activeCanvasWorkspaceType}
workspaceLabel={workspaceLabel}
createUrl={createNewCanvasUrl}
buildProjectUrl={buildWorkspaceProjectUrl}
/>
)}
</div>
</div>
{/* 5. Floating Toolbar (Bottom) */} {/* 5. Floating Toolbar (Bottom) */}
<div <div

View File

@ -44,6 +44,7 @@ interface CanvasWorkspaceProps {
workspaceLabel?: string; workspaceLabel?: string;
emptyStateDescription?: string; emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video"; workspaceVariant?: "canvas" | "video";
showEmptyState?: boolean;
} }
export function CanvasWorkspace({ export function CanvasWorkspace({
@ -51,6 +52,7 @@ export function CanvasWorkspace({
workspaceLabel = "画布", workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作', emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas", workspaceVariant = "canvas",
showEmptyState = true,
}: CanvasWorkspaceProps) { }: CanvasWorkspaceProps) {
const SNAP_THRESHOLD = 10; const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12; const SNAP_GAP = 12;
@ -516,7 +518,20 @@ export function CanvasWorkspace({
if (connectionDraft) { if (connectionDraft) {
return; 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); setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
e.preventDefault(); e.preventDefault();
@ -531,7 +546,7 @@ export function CanvasWorkspace({
setMarqueeStart(start); setMarqueeStart(start);
setMarqueeEnd(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) => { 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"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isWorkflowCanvas ? ( {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"> <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">