4 changed files with 397 additions and 6 deletions
@ -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> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue