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