Browse Source

节点导航

feature/composer
TianYun 3 weeks ago
parent
commit
703b767b2d
  1. 323
      src/components/CanvasOutlineDrawer.tsx
  2. 30
      src/components/FloatingActionBar.tsx
  3. 10
      src/components/WorkflowCanvas.tsx
  4. 40
      src/i18n/index.tsx

323
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<NodeType, TranslationKey> = {
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 | number>) => 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<string, unknown>;
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 <img src={previewUrl} alt="" className="h-full w-full object-cover" />;
}
if (isAudioNode(type)) {
return <AudioOutlined className="text-[15px]" />;
}
if (isVideoNode(type)) {
return <PlayCircleOutlined className="text-[15px]" />;
}
if (isTextNode(type)) {
return <FileTextOutlined className="text-[15px]" />;
}
return <FileImageOutlined className="text-[15px]" />;
}
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<Set<string>>(() => new Set());
const [query, setQuery] = useState("");
const outline = useMemo(() => {
const visibleNodes = nodes.filter((node) => node.type !== "group");
const visibleGroupIds = new Set<string>();
const nodeTitles = new Map<string, string>();
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<OutlineGroup>((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 (
<button
key={node.id}
type="button"
onClick={() => handleNodeClick(node.id)}
title={title}
className={`flex h-10 w-full min-w-0 items-center gap-2 rounded-md border-0 bg-transparent py-0 pr-2 text-left text-sm font-medium text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] ${
node.selected ? "bg-[var(--surface-active)]" : ""
} ${nested ? "pl-7" : "pl-1"}`}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md bg-[var(--surface-3)] text-[var(--text-secondary)]">
<NodeIcon node={node} />
</span>
<span className="min-w-0 flex-1 truncate">{title}</span>
</button>
);
};
const hasVisibleItems = filteredOutline.rootNodes.length > 0 || filteredOutline.groups.length > 0;
return (
<Drawer
open={open}
onClose={onClose}
placement="left"
width={280}
title={t("canvasOutline.title")}
classNames={{
mask: "!bg-black/20",
content: "!bg-[var(--surface-2)] !text-[var(--text-primary)]",
header: "!border-[var(--border-subtle)] !px-4 !py-3",
body: "!flex !h-full !flex-col !gap-3 !p-3",
}}
>
<Input.Search
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("canvasOutline.searchPlaceholder")}
allowClear
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{hasVisibleItems ? (
<div className="flex flex-col gap-1">
{filteredOutline.rootNodes.map((node) => renderNodeRow(node))}
{filteredOutline.groups.map((entry) => {
const collapsed = collapsedGroupIds.has(entry.group.id) && !query.trim();
return (
<div key={entry.group.id} className="flex flex-col gap-1">
<div className="group flex h-9 w-full min-w-0 items-center gap-1 rounded-md pr-2 text-sm font-semibold text-[var(--text-primary)] hover:bg-[var(--surface-hover)]">
<button
type="button"
onClick={() => toggleGroup(entry.group.id)}
aria-label={collapsed ? t("canvasOutline.expandGroup") : t("canvasOutline.collapseGroup")}
className="flex h-8 w-6 shrink-0 items-center justify-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]"
>
<CaretDownOutlined className={`text-[11px] transition-transform ${collapsed ? "-rotate-90" : "rotate-0"}`} />
</button>
<button
type="button"
onClick={() => handleNodeClick(entry.nodeId)}
className="flex min-w-0 flex-1 items-center gap-2 border-0 bg-transparent p-0 text-left"
title={entry.group.name}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--surface-3)] text-[var(--text-secondary)]">
{collapsed ? <FolderOutlined className="text-[15px]" /> : <FolderOpenOutlined className="text-[15px]" />}
</span>
<span className="min-w-0 flex-1 truncate">{entry.group.name}</span>
<span className="shrink-0 text-xs font-normal text-[var(--text-muted)]">
{t("canvasOutline.groupNodeCount", { count: entry.children.length })}
</span>
</button>
</div>
{!collapsed && entry.children.map((node) => renderNodeRow(node, true))}
</div>
);
})}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={query.trim() ? t("canvasOutline.noSearchResults") : t("canvasOutline.empty")}
/>
)}
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-t border-[var(--border-subtle)] pt-2 text-xs text-[var(--text-muted)]">
<span className="flex items-center gap-1.5">
<NodeIndexOutlined className="text-[13px]" />
{t("canvasOutline.totalNodes", { count: outline.totalNodeCount })}
</span>
</div>
</Drawer>
);
}

30
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 (
<>
<button
onClick={() => setIsOutlineOpen(true)}
title={t("canvasOutline.title")}
aria-label={t("canvasOutline.title")}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
<NodeIndexOutlined className="text-[18px]" />
<span className="sr-only">{t("canvasOutline.title")}</span>
</button>
{isOutlineOpen && (
<CanvasOutlineDrawer
open={isOutlineOpen}
onClose={() => setIsOutlineOpen(false)}
/>
)}
</>
);
}
export function FloatingActionBar() {
const { t } = useI18n();
const nodes = useWorkflowStore((state) => state.nodes) ?? [];
@ -268,6 +295,7 @@ export function FloatingActionBar() {
<div className="fixed left-5 top-1/2 z-50 -translate-y-1/2">
<div className="flex flex-col items-center gap-2 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] px-1.5 py-1.5 shadow-[var(--shadow-panel)]">
<GenerateComboButton />
<CanvasOutlineButton />
<AssetLibraryButton />
<MyCreationButton />

10
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<string, Node>(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 });

40
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": "動画入力",

Loading…
Cancel
Save