From 703b767b2dce962004c72c60c595797c3cf6b9df Mon Sep 17 00:00:00 2001 From: TianYun Date: Tue, 23 Jun 2026 17:44:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CanvasOutlineDrawer.tsx | 323 +++++++++++++++++++++++++ src/components/FloatingActionBar.tsx | 30 ++- src/components/WorkflowCanvas.tsx | 10 +- src/i18n/index.tsx | 40 +++ 4 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 src/components/CanvasOutlineDrawer.tsx diff --git a/src/components/CanvasOutlineDrawer.tsx b/src/components/CanvasOutlineDrawer.tsx new file mode 100644 index 00000000..ab5004a0 --- /dev/null +++ b/src/components/CanvasOutlineDrawer.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Drawer, Empty, Input } from "antd"; +import { + AudioOutlined, + CaretDownOutlined, + FileImageOutlined, + FileTextOutlined, + FolderOpenOutlined, + FolderOutlined, + NodeIndexOutlined, + PlayCircleOutlined, +} from "@ant-design/icons"; +import { useShallow } from "zustand/shallow"; + +import { useI18n, type TranslationKey } from "@/i18n"; +import { useWorkflowStore } from "@/store/workflowStore"; +import type { NodeGroup, NodeType, WorkflowNode } from "@/types"; + +interface CanvasOutlineDrawerProps { + open: boolean; + onClose: () => void; +} + +type OutlineGroup = { + group: NodeGroup; + nodeId: string; + children: WorkflowNode[]; +}; + +const NODE_TITLE_KEYS: Record = { + group: "canvasOutline.group", + imageInput: "node.imageInput", + smartImage: "node.image", + smartVideo: "node.generateVideo", + smartAudio: "toolbar.audio", + audioInput: "node.audioInput", + videoInput: "node.videoInput", + annotation: "node.annotation", + prompt: "node.prompt", + smartText: "smartText.title", + array: "node.array", + promptConstructor: "node.promptConstructor", + nanoBanana: "node.generateImage", + generateVideo: "node.generateVideo", + generate3d: "node.generate3d", + generateAudio: "node.generateAudio", + llmGenerate: "node.llmGenerate", + splitGrid: "node.splitGrid", + output: "node.output", + outputGallery: "node.outputGallery", + imageCompare: "node.imageCompare", + videoStitch: "node.videoStitch", + easeCurve: "node.easeCurve", + videoTrim: "node.videoTrim", + videoFrameGrab: "node.frameGrab", + router: "node.router", + switch: "node.switch", + conditionalSwitch: "node.conditionalSwitch", + glbViewer: "node.glbViewer", +}; + +function getGroupNodeId(groupId: string): string { + return `group-node-${groupId}`; +} + +function stringField(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function getNodeDisplayTitle(node: WorkflowNode, t: (key: TranslationKey, values?: Record) => string): string { + const customTitle = stringField(node.data?.customTitle); + if (customTitle) return customTitle; + + const key = NODE_TITLE_KEYS[node.type as NodeType]; + return key ? t(key) : node.type || t("canvasOutline.nodeFallback"); +} + +function getNodePreviewUrl(node: WorkflowNode): string | null { + const data = node.data as Record; + const candidates = [ + data.previewImage, + data.previewImg, + data.image, + data.outputImage, + data.coverImage, + data.previewVideoPoster, + data.videoPoster, + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate; + } + } + + return null; +} + +function isTextNode(type: NodeType): boolean { + return type === "prompt" || type === "smartText" || type === "promptConstructor" || type === "llmGenerate" || type === "array"; +} + +function isAudioNode(type: NodeType): boolean { + return type === "audioInput" || type === "smartAudio" || type === "generateAudio"; +} + +function isVideoNode(type: NodeType): boolean { + return type === "videoInput" || type === "smartVideo" || type === "generateVideo" || type === "videoTrim" || type === "videoStitch" || type === "videoFrameGrab"; +} + +function NodeIcon({ node }: { node: WorkflowNode }) { + const type = node.type as NodeType; + const previewUrl = getNodePreviewUrl(node); + + if (previewUrl) { + return ; + } + + if (isAudioNode(type)) { + return ; + } + + if (isVideoNode(type)) { + return ; + } + + if (isTextNode(type)) { + return ; + } + + return ; +} + +function matchesQuery(value: string, query: string): boolean { + return value.toLowerCase().includes(query.toLowerCase()); +} + +export function CanvasOutlineDrawer({ open, onClose }: CanvasOutlineDrawerProps) { + const { t } = useI18n(); + const { nodes, groups, selectSingleNode, setNavigationTarget } = useWorkflowStore(useShallow((state) => ({ + nodes: state.nodes, + groups: state.groups, + selectSingleNode: state.selectSingleNode, + setNavigationTarget: state.setNavigationTarget, + }))); + const [collapsedGroupIds, setCollapsedGroupIds] = useState>(() => new Set()); + const [query, setQuery] = useState(""); + + const outline = useMemo(() => { + const visibleNodes = nodes.filter((node) => node.type !== "group"); + const visibleGroupIds = new Set(); + const nodeTitles = new Map(); + + for (const node of visibleNodes) { + nodeTitles.set(node.id, getNodeDisplayTitle(node, t)); + if (node.groupId && groups[node.groupId]) { + visibleGroupIds.add(node.groupId); + } + } + + const groupEntries = Object.values(groups) + .filter((group) => visibleGroupIds.has(group.id)) + .map((group) => ({ + group, + nodeId: getGroupNodeId(group.id), + children: visibleNodes + .filter((node) => node.groupId === group.id || node.parentId === getGroupNodeId(group.id)) + .sort((left, right) => left.position.y - right.position.y || left.position.x - right.position.x), + })) + .sort((left, right) => left.group.position.y - right.group.position.y || left.group.position.x - right.group.position.x); + + const groupedNodeIds = new Set(groupEntries.flatMap((entry) => entry.children.map((node) => node.id))); + const rootNodes = visibleNodes + .filter((node) => !groupedNodeIds.has(node.id)) + .sort((left, right) => left.position.y - right.position.y || left.position.x - right.position.x); + + return { + nodeTitles, + rootNodes, + groups: groupEntries, + totalNodeCount: visibleNodes.length, + }; + }, [groups, nodes, t]); + + const filteredOutline = useMemo(() => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return outline; + + const rootNodes = outline.rootNodes.filter((node) => matchesQuery(outline.nodeTitles.get(node.id) ?? node.id, trimmedQuery)); + const groups = outline.groups + .map((entry) => { + const groupMatches = matchesQuery(entry.group.name, trimmedQuery); + const children = groupMatches + ? entry.children + : entry.children.filter((node) => matchesQuery(outline.nodeTitles.get(node.id) ?? node.id, trimmedQuery)); + return children.length > 0 || groupMatches ? { ...entry, children } : null; + }) + .filter((entry): entry is OutlineGroup => Boolean(entry)); + + return { + ...outline, + rootNodes, + groups, + }; + }, [outline, query]); + + const handleNodeClick = (nodeId: string) => { + selectSingleNode(nodeId); + setNavigationTarget(nodeId); + }; + + const toggleGroup = (groupId: string) => { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + const renderNodeRow = (node: WorkflowNode, nested = false) => { + const title = outline.nodeTitles.get(node.id) ?? getNodeDisplayTitle(node, t); + return ( + + ); + }; + + const hasVisibleItems = filteredOutline.rootNodes.length > 0 || filteredOutline.groups.length > 0; + + return ( + + setQuery(event.target.value)} + placeholder={t("canvasOutline.searchPlaceholder")} + allowClear + /> + +
+ {hasVisibleItems ? ( +
+ {filteredOutline.rootNodes.map((node) => renderNodeRow(node))} + + {filteredOutline.groups.map((entry) => { + const collapsed = collapsedGroupIds.has(entry.group.id) && !query.trim(); + return ( +
+
+ + +
+ {!collapsed && entry.children.map((node) => renderNodeRow(node, true))} +
+ ); + })} +
+ ) : ( + + )} +
+ +
+ + + {t("canvasOutline.totalNodes", { count: outline.totalNodeCount })} + +
+
+ ); +} diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 36c65bfa..667a0e31 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FolderOpenOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons"; +import { FolderOpenOutlined, NodeIndexOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons"; import { useWorkflowStore } from "@/store/workflowStore"; import { NodeType } from "@/types"; import { useReactFlow } from "@xyflow/react"; @@ -9,6 +9,7 @@ import { useI18n } from "@/i18n"; import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel"; import { GenerateNodeMenu } from "./GenerateNodeMenu"; import { AssetLibraryDrawer } from "./AssetLibraryDrawer"; +import { CanvasOutlineDrawer } from "./CanvasOutlineDrawer"; import { MyCreationPickerModal } from "./MyCreationPickerModal"; import { useFTUXStore, TutorialStep } from "@/store/ftuxStore"; @@ -139,6 +140,32 @@ function AssetLibraryButton() { ); } +function CanvasOutlineButton() { + const { t } = useI18n(); + const [isOutlineOpen, setIsOutlineOpen] = useState(false); + + return ( + <> + + + {isOutlineOpen && ( + setIsOutlineOpen(false)} + /> + )} + + ); +} + export function FloatingActionBar() { const { t } = useI18n(); const nodes = useWorkflowStore((state) => state.nodes) ?? []; @@ -268,6 +295,7 @@ export function FloatingActionBar() {
+ diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 2f2e1f3d..255a6181 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -820,11 +820,11 @@ export function WorkflowCanvas() { if (navigationTarget) { const targetNode = nodes.find((n) => n.id === navigationTarget.nodeId); if (targetNode) { - // Calculate center of node - const nodeWidth = (targetNode.style?.width as number) || 300; - const nodeHeight = (targetNode.style?.height as number) || 280; - const centerX = targetNode.position.x + nodeWidth / 2; - const centerY = targetNode.position.y + nodeHeight / 2; + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const position = getWorkflowNodeAbsolutePosition(targetNode, nodeById); + const dimensions = getWorkflowNodeDimensions(targetNode); + const centerX = position.x + dimensions.width / 2; + const centerY = position.y + dimensions.height / 2; // Navigate to node center with animation, zoomed out to 0.7 for better context setCenter(centerX, centerY, { duration: 300, zoom: 0.7 }); diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 1f48ec01..46fe9fb5 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -405,6 +405,16 @@ const en = { "toolbar.runSelectedNodesCount": "Run {count} selected nodes", "toolbar.selectSingleNode": "Select a single node first", "toolbar.selectOneOrMoreNodes": "Select one or more nodes first", + "canvasOutline.title": "Canvas outline", + "canvasOutline.group": "Group", + "canvasOutline.nodeFallback": "Node", + "canvasOutline.searchPlaceholder": "Search nodes", + "canvasOutline.empty": "No nodes on the canvas", + "canvasOutline.noSearchResults": "No matching nodes", + "canvasOutline.totalNodes": "{count} nodes", + "canvasOutline.groupNodeCount": "{count} nodes", + "canvasOutline.expandGroup": "Expand group", + "canvasOutline.collapseGroup": "Collapse group", "node.imageInput": "Image Input", "node.audioInput": "Audio Input", "node.videoInput": "Video Input", @@ -1468,6 +1478,16 @@ const zhCN: Dictionary = { "toolbar.runSelectedNodesCount": "运行 {count} 个选中节点", "toolbar.selectSingleNode": "请先选择一个节点", "toolbar.selectOneOrMoreNodes": "请先选择一个或多个节点", + "canvasOutline.title": "画布列表", + "canvasOutline.group": "分组", + "canvasOutline.nodeFallback": "节点", + "canvasOutline.searchPlaceholder": "搜索节点", + "canvasOutline.empty": "画布上没有节点", + "canvasOutline.noSearchResults": "没有匹配的节点", + "canvasOutline.totalNodes": "共 {count} 个节点", + "canvasOutline.groupNodeCount": "{count} 个节点", + "canvasOutline.expandGroup": "展开分组", + "canvasOutline.collapseGroup": "收起分组", "node.imageInput": "图片输入", "node.audioInput": "音频输入", "node.videoInput": "视频输入", @@ -2377,6 +2397,16 @@ const zhTW: Dictionary = { "toolbar.runSelectedOnly": "僅執行選中節點", "toolbar.runSelectedNodes": "執行選中節點", "toolbar.runSelectedNodesCount": "執行 {count} 個選中節點", + "canvasOutline.title": "畫布列表", + "canvasOutline.group": "群組", + "canvasOutline.nodeFallback": "節點", + "canvasOutline.searchPlaceholder": "搜尋節點", + "canvasOutline.empty": "畫布上沒有節點", + "canvasOutline.noSearchResults": "沒有符合的節點", + "canvasOutline.totalNodes": "共 {count} 個節點", + "canvasOutline.groupNodeCount": "{count} 個節點", + "canvasOutline.expandGroup": "展開群組", + "canvasOutline.collapseGroup": "收合群組", "node.imageInput": "圖片輸入", "node.audioInput": "音訊輸入", "node.videoInput": "影片輸入", @@ -3111,6 +3141,16 @@ const ja: Dictionary = { "toolbar.runSelectedNodesCount": "選択した {count} ノードを実行", "toolbar.selectSingleNode": "先に 1 つのノードを選択してください", "toolbar.selectOneOrMoreNodes": "先に 1 つ以上のノードを選択してください", + "canvasOutline.title": "キャンバス一覧", + "canvasOutline.group": "グループ", + "canvasOutline.nodeFallback": "ノード", + "canvasOutline.searchPlaceholder": "ノードを検索", + "canvasOutline.empty": "キャンバスにノードがありません", + "canvasOutline.noSearchResults": "一致するノードがありません", + "canvasOutline.totalNodes": "{count} 個のノード", + "canvasOutline.groupNodeCount": "{count} 個のノード", + "canvasOutline.expandGroup": "グループを展開", + "canvasOutline.collapseGroup": "グループを折りたたむ", "node.imageInput": "画像入力", "node.audioInput": "音声入力", "node.videoInput": "動画入力",