Refine video canvas workspace UX
This commit is contained in:
parent
be5b9aef12
commit
f6b9a6f73b
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user