diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/UniappTool.xml b/.idea/UniappTool.xml new file mode 100644 index 0000000..0980911 --- /dev/null +++ b/.idea/UniappTool.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..b1ff323 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/data/imagegen.sqlite + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/imagegen-tools.iml b/.idea/imagegen-tools.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/imagegen-tools.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d9e60a9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/data/imagegen.sqlite-wal b/data/imagegen.sqlite-wal index 94f8202..4d7cc4e 100644 Binary files a/data/imagegen.sqlite-wal and b/data/imagegen.sqlite-wal differ diff --git a/docs/canvas-collaboration-extension.md b/docs/canvas-collaboration-extension.md new file mode 100644 index 0000000..ee167ba --- /dev/null +++ b/docs/canvas-collaboration-extension.md @@ -0,0 +1,55 @@ +# 画布多人协作保存扩展需求 + +> 扩展需求:当前暂不实现。 + +## 背景 + +当前画布编辑器采用单人编辑模型。前端在画布数据变化后通过 debounce 触发保存,请求 `PATCH /api/canvas/projects/[id]`,服务端将画布数据作为整份 JSON 写入 `canvas_projects.data`。 + +这个机制适合单人场景:用户新增节点、移动节点、编辑 prompt、删除连接等操作后,只需要可靠地把最新画布状态持久化即可。当前保存状态可以继续围绕 HTTP 请求结果展示,例如“保存中”“已同步”“保存失败”。 + +## 判断结论 + +单人保存不需要 WebSocket。现有 HTTP PATCH 自动保存已经能覆盖主要需求,后续更应该优先完善保存状态、错误提示和失败重试。 + +多人实时协作需要实时通道。WebSocket 适合承载协作者加入、在线状态、光标/选区、画布变更广播和冲突提示,但它只解决消息传输,不自动解决权限、身份、冲突合并和最终落库。 + +由于项目未来部署形态暂不确定,协作能力应通过 transport adapter 隔离。这样本机或内网 Node 部署可以使用自建 WebSocket server,Serverless 部署可以替换为托管实时服务,而上层画布协作协议保持一致。 + +## 第一版目标 + +- 提供轻量实时协作,而不是严格无冲突协同编辑。 +- 广播节点和连接的增删改结果,让其他已打开同一画布的客户端自动同步。 +- 广播在线状态、光标位置和选中节点,帮助用户知道其他协作者正在查看或编辑的位置。 +- 通过版本号检测基础冲突,避免旧客户端静默覆盖新状态。 +- 保留现有 HTTP PATCH 作为最终落库和实时通道不可用时的回退路径。 + +## 非目标 + +- 当前不实现严格 CRDT 或操作日志合并。 +- 当前不引入用户登录、权限模型或项目成员体系。 +- 当前不改造图片生成任务的 SSE 监听机制。 +- 当前不修改数据库 schema、前端画布组件或运行时配置。 + +## 未来方案草案 + +后续实现时,可以为画布项目引入版本字段,例如 `version: number`。客户端提交协作变更时携带 `baseVersion`,服务端仅接受基于当前版本的变更。服务端接受变更后递增版本、写入数据库,并向同一画布房间内的其他客户端广播新的画布状态。 + +建议的协作事件包括: + +- `join_canvas`:客户端进入画布,携带 `projectId`、`clientId` 和显示名称。 +- `presence`:广播在线协作者、光标位置和选中状态。 +- `canvas_patch`:客户端提交画布变更,携带 `baseVersion` 和变更后的画布数据。 +- `canvas_state`:服务端确认并广播最新 `version` 和画布数据。 +- `conflict`:客户端基于旧版本提交时返回,提示客户端重新同步。 + +第一版冲突策略可以采用版本号加后写覆盖保护:服务端拒绝旧版本提交,客户端收到 `conflict` 后重新拉取或应用服务端状态。暂不尝试自动合并同一节点字段的并发修改。 + +## 测试场景 + +- 单人保存:新增节点、移动节点、编辑 prompt、删除连接后,保存状态从“保存中”回到“已同步”。 +- 双窗口同步:两个窗口打开同一画布,窗口 A 新增节点后,窗口 B 无需刷新即可看到更新。 +- 冲突处理:两个窗口基于同一旧版本同时修改,后提交方收到冲突提示,不能静默覆盖服务端状态。 +- 断线回退:实时通道断开后,客户端显示离线或错误状态,并可通过 HTTP PATCH 回退保存。 +- 生成任务回归:图片生成期间的 loading/result 节点仍能正确保存和同步,现有 SSE 任务监听保持不变。 + diff --git a/public/generated/1076263b-f569-46ad-8def-cdab26e83782.png b/public/generated/1076263b-f569-46ad-8def-cdab26e83782.png new file mode 100644 index 0000000..0efc7ef Binary files /dev/null and b/public/generated/1076263b-f569-46ad-8def-cdab26e83782.png differ diff --git a/public/generated/6240b5e9-5fad-46a2-ae2e-9fe33500c07b.png b/public/generated/6240b5e9-5fad-46a2-ae2e-9fe33500c07b.png new file mode 100644 index 0000000..d8c1b24 Binary files /dev/null and b/public/generated/6240b5e9-5fad-46a2-ae2e-9fe33500c07b.png differ diff --git a/public/generated/867c0cec-e5b0-4050-addb-1e78c57709e3.png b/public/generated/867c0cec-e5b0-4050-addb-1e78c57709e3.png new file mode 100644 index 0000000..d090e55 Binary files /dev/null and b/public/generated/867c0cec-e5b0-4050-addb-1e78c57709e3.png differ diff --git a/public/generated/986eba8a-3c51-478f-9443-79e2c47da065.png b/public/generated/986eba8a-3c51-478f-9443-79e2c47da065.png new file mode 100644 index 0000000..0efc7ef Binary files /dev/null and b/public/generated/986eba8a-3c51-478f-9443-79e2c47da065.png differ diff --git a/public/generated/aaad7aac-ec4d-4746-9e8e-77842716dc9b.png b/public/generated/aaad7aac-ec4d-4746-9e8e-77842716dc9b.png new file mode 100644 index 0000000..10f05c4 Binary files /dev/null and b/public/generated/aaad7aac-ec4d-4746-9e8e-77842716dc9b.png differ diff --git a/public/generated/e3c036a2-c311-4be0-8970-d4b4b14dac7f.png b/public/generated/e3c036a2-c311-4be0-8970-d4b4b14dac7f.png new file mode 100644 index 0000000..3053217 Binary files /dev/null and b/public/generated/e3c036a2-c311-4be0-8970-d4b4b14dac7f.png differ diff --git a/public/generated/ea78b81b-8487-4061-bef6-0689554b6c5a.png b/public/generated/ea78b81b-8487-4061-bef6-0689554b6c5a.png new file mode 100644 index 0000000..d090e55 Binary files /dev/null and b/public/generated/ea78b81b-8487-4061-bef6-0689554b6c5a.png differ diff --git a/src/app/api/canvas/projects/[id]/export/route.ts b/src/app/api/canvas/projects/[id]/export/route.ts index 0e3791d..d71fd03 100644 --- a/src/app/api/canvas/projects/[id]/export/route.ts +++ b/src/app/api/canvas/projects/[id]/export/route.ts @@ -22,6 +22,7 @@ export async function GET( return NextResponse.json({ item: createCanvasProjectExport({ title: project.title, + description: project.description, data: project.data, }), }); diff --git a/src/app/api/canvas/projects/[id]/route.ts b/src/app/api/canvas/projects/[id]/route.ts index 542f23e..aa37643 100644 --- a/src/app/api/canvas/projects/[id]/route.ts +++ b/src/app/api/canvas/projects/[id]/route.ts @@ -36,12 +36,16 @@ export async function PATCH( const payload = await request.json().catch(() => ({})); const patch: { title?: string; + description?: string; data?: CanvasProjectData; } = {}; if (typeof payload.title === "string") { patch.title = payload.title; } + if (typeof payload.description === "string") { + patch.description = payload.description; + } if ("data" in payload) { const data = normalizeCanvasProjectData(payload.data); if (!data) { @@ -55,6 +59,7 @@ export async function PATCH( const project = updateCanvasProject(id, { title: patch.title, + description: patch.description, data: patch.data, }); diff --git a/src/app/api/canvas/projects/route.ts b/src/app/api/canvas/projects/route.ts index c282433..3069364 100644 --- a/src/app/api/canvas/projects/route.ts +++ b/src/app/api/canvas/projects/route.ts @@ -20,8 +20,10 @@ export async function POST(request: Request) { typeof payload.title === "string" && payload.title.trim() ? payload.title : "未命名画布"; + const description = + typeof payload.description === "string" ? payload.description : ""; const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData; - const project = createCanvasProject({ title, data }); + const project = createCanvasProject({ title, description, data }); return NextResponse.json({ item: project }, { status: 201 }); } diff --git a/src/app/api/images/events/route.ts b/src/app/api/images/events/route.ts new file mode 100644 index 0000000..4b18c10 --- /dev/null +++ b/src/app/api/images/events/route.ts @@ -0,0 +1,9 @@ +import { streamImageJobEvents } from "@/lib/server/services/image-job-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +export async function GET(request: Request) { + return streamImageJobEvents(request); +} diff --git a/src/app/globals.css b/src/app/globals.css index cd27ed4..4bb9212 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -30,3 +30,9 @@ textarea, select { letter-spacing: 0; } + +@keyframes canvas-connection-flow { + to { + stroke-dashoffset: -26; + } +} diff --git a/src/components/canvas/canvas-connections.tsx b/src/components/canvas/canvas-connections.tsx index 9a54762..e795905 100644 --- a/src/components/canvas/canvas-connections.tsx +++ b/src/components/canvas/canvas-connections.tsx @@ -1,7 +1,12 @@ "use client"; import type { MouseEvent as ReactMouseEvent } from "react"; -import type { CanvasConnection, CanvasNode, CanvasPosition } from "@/lib/canvas/types"; +import type { + CanvasConnection, + CanvasImageNodeMetadata, + CanvasNode, + CanvasPosition, +} from "@/lib/canvas/types"; import { getActiveConnectionPath, getConnectionPath } from "@/lib/canvas/geometry"; type CanvasConnectionsProps = { @@ -41,6 +46,7 @@ export function CanvasConnections({ void; }) { const path = getConnectionPath(from, to); - const stroke = active ? "#18181b" : "#a1a1aa"; + const stroke = active || animated ? "#18181b" : "#a1a1aa"; function handleContextMenu(event: ReactMouseEvent) { event.preventDefault(); @@ -109,13 +117,35 @@ function ConnectionPath({ fill="none" stroke={stroke} strokeLinecap="round" - strokeOpacity={active ? 1 : 0.72} - strokeWidth={active ? 3 : 2} + strokeOpacity={active || animated ? 1 : 0.72} + strokeWidth={active || animated ? 3 : 2} style={{ - filter: active ? "drop-shadow(0 0 8px rgba(17,24,39,.25))" : undefined, + filter: + active || animated + ? "drop-shadow(0 0 8px rgba(17,24,39,.25))" + : undefined, pointerEvents: "none", }} /> + {animated ? ( + + ) : null} ); } + +function isConnectionAnimating(to: CanvasNode) { + if (to.type !== "image") return false; + return (to.metadata as CanvasImageNodeMetadata).status === "loading"; +} diff --git a/src/components/canvas/canvas-editor-client.tsx b/src/components/canvas/canvas-editor-client.tsx index 858f8cb..3f426b5 100644 --- a/src/components/canvas/canvas-editor-client.tsx +++ b/src/components/canvas/canvas-editor-client.tsx @@ -2,8 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import type { MouseEvent as ReactMouseEvent } from "react"; -import { FileText, ImageIcon } from "lucide-react"; +import type { + DragEvent as ReactDragEvent, + MouseEvent as ReactMouseEvent, +} from "react"; +import { FileText, ImageIcon, X } from "lucide-react"; +import { toast, Toaster } from "sonner"; import { Button } from "@/components/ui/button"; import { CanvasConnections } from "@/components/canvas/canvas-connections"; import { CanvasNode } from "@/components/canvas/canvas-node"; @@ -16,15 +20,20 @@ import { screenToWorld, } from "@/lib/canvas/geometry"; import { resolveCanvasGenerationInput } from "@/lib/canvas/generation"; -import { getPromptMentionSources } from "@/lib/canvas/prompt-mentions"; +import { + getConnectedPromptSources, + getPromptMentionSources, +} from "@/lib/canvas/prompt-mentions"; import { createCanvasNode, + createImageLoadingNode, createImageNodeFromHistory, createImageNodeFromUpload, createImageResultNode, } from "@/lib/canvas/node-factory"; import type { CanvasConnection, + CanvasImageNodeMetadata, CanvasProjectData, CanvasNode as CanvasNodeType, CanvasNodeType as CanvasNodeKind, @@ -39,6 +48,7 @@ import { normalizeGenerationError, normalizeImageJobPayload, parseJsonResponse, + watchImageJob, type HistoryItem, type ImageJobPayload, } from "@/lib/image-workflow"; @@ -69,9 +79,24 @@ type UploadPayload = { }; }; +type CanvasNodeClipboard = { + kind: "imagegen.canvas.nodes"; + version: 1; + nodes: CanvasNodeType[]; + connections: CanvasConnection[]; +}; + const SAVE_DEBOUNCE_MS = 400; const EMPTY_NODES: CanvasNodeType[] = []; const EMPTY_CONNECTIONS: CanvasConnection[] = []; +const activeCanvasJobStoragePrefix = "imagegen:canvas:active-job:"; + +type StoredCanvasJob = { + jobId: string; + loadingNodeId: string; + clientRequestId: string; + requestStartedAt: number; +}; export function CanvasEditorClient({ projectId }: { projectId: string }) { const router = useRouter(); @@ -81,6 +106,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const projectRef = useRef(null); const undoStackRef = useRef([]); const redoStackRef = useRef([]); + const jobWatcherCleanupRef = useRef<(() => void) | null>(null); + const nodeClipboardRef = useRef(null); + const pasteCountRef = useRef(0); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -96,9 +124,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const [selectionBox, setSelectionBox] = useState(null); const [mouseWorld, setMouseWorld] = useState({ x: 160, y: 140 }); const [job, setJob] = useState(null); - const [error, setError] = useState(null); const [showLibrary, setShowLibrary] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); + const [showShortcutsDialog, setShowShortcutsDialog] = useState(false); const [importJson, setImportJson] = useState(""); const [isImporting, setIsImporting] = useState(false); const [selectionMode, setSelectionMode] = useState(false); @@ -124,7 +152,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { if (!cancelled) setProject(payload.item as CanvasProjectRecord); } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : "加载画布失败"); + toast.error(err instanceof Error ? err.message : "加载画布失败"); } } finally { if (!cancelled) setIsLoading(false); @@ -136,9 +164,21 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { return () => { cancelled = true; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + jobWatcherCleanupRef.current?.(); + jobWatcherCleanupRef.current = null; }; }, [projectId]); + useEffect(() => { + if (!project || jobWatcherCleanupRef.current) return; + const storedJob = readStoredCanvasJob(projectId); + if (storedJob) { + resumeCanvasJob(storedJob); + } + // Restore only the saved job for this loaded canvas. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project, projectId]); + useEffect(() => { let cancelled = false; @@ -164,7 +204,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; const promptMentionSources = useMemo( () => - selectedNode?.type === "prompt" + selectedNode?.type === "prompt" || selectedNode?.type === "image" ? getPromptMentionSources(nodes, connections, selectedNode.id) : [], [connections, nodes, selectedNode], @@ -185,6 +225,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { return labels; }, [connections, nodes]); + const promptReferenceLabels = useMemo(() => { + const labels = new Map(); + + for (const node of nodes) { + for (const source of getConnectedPromptSources(nodes, connections, node.id)) { + if (!labels.has(source.nodeId)) labels.set(source.nodeId, source.label); + } + } + + return labels; + }, [connections, nodes]); const relatedNodeIds = useMemo( () => getRelatedNodeIds(connections, selectedNodeId), [connections, selectedNodeId], @@ -346,6 +397,121 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setSelectionBox(null); } + async function copySelectedNodes() { + const current = projectRef.current; + if (!current || !selectedNodeIds.length) return; + + const selectedIds = new Set(selectedNodeIds); + const clipboard: CanvasNodeClipboard = { + kind: "imagegen.canvas.nodes", + version: 1, + nodes: current.data.nodes + .filter((node) => selectedIds.has(node.id)) + .map(cloneCanvasNode), + connections: current.data.connections + .filter( + (connection) => + selectedIds.has(connection.fromNodeId) && + selectedIds.has(connection.toNodeId), + ) + .map(cloneCanvasConnection), + }; + if (!clipboard.nodes.length) return; + + nodeClipboardRef.current = clipboard; + pasteCountRef.current = 0; + try { + await navigator.clipboard.writeText(JSON.stringify(clipboard)); + } catch { + // Browser clipboard permissions can fail; keep the in-app clipboard usable. + } + toast.success( + clipboard.nodes.length > 1 + ? `已复制 ${clipboard.nodes.length} 个节点` + : "已复制节点", + ); + } + + async function pasteCopiedNodes() { + const clipboard = await readNodeClipboard(nodeClipboardRef.current); + if (!clipboard?.nodes.length) return false; + + nodeClipboardRef.current = clipboard; + pasteCountRef.current += 1; + const offset = 36 * pasteCountRef.current; + const idMap = new Map(); + const nextNodes = clipboard.nodes.map((node) => { + const nextId = crypto.randomUUID(); + idMap.set(node.id, nextId); + return { + ...cloneCanvasNode(node), + id: nextId, + position: { + x: node.position.x + offset, + y: node.position.y + offset, + }, + }; + }); + const nextConnections = clipboard.connections + .map((connection) => { + const fromNodeId = idMap.get(connection.fromNodeId); + const toNodeId = idMap.get(connection.toNodeId); + if (!fromNodeId || !toNodeId) return null; + return { + ...cloneCanvasConnection(connection), + id: crypto.randomUUID(), + fromNodeId, + toNodeId, + } satisfies CanvasConnection; + }) + .filter((connection): connection is CanvasConnection => Boolean(connection)); + + patchProjectData((current) => ({ + ...current, + data: { + ...current.data, + nodes: [...current.data.nodes, ...nextNodes], + connections: [...current.data.connections, ...nextConnections], + }, + })); + setSelectedNodeIds(nextNodes.map((node) => node.id)); + setSelectedNodeId(nextNodes.at(-1)?.id ?? null); + setSelectedConnectionId(null); + setActiveConnection(null); + setSelectionBox(null); + toast.success(nextNodes.length > 1 ? `已粘贴 ${nextNodes.length} 个节点` : "已粘贴节点"); + return true; + } + + async function pasteFromSystemClipboard(event?: ClipboardEvent) { + if (await pasteCopiedNodes()) return; + + const items = event?.clipboardData?.items; + if (items) { + const imageItem = Array.from(items).find((item) => + item.type.startsWith("image/"), + ); + const imageFile = imageItem?.getAsFile(); + if (imageFile) { + await uploadImage(imageFile); + return; + } + + const text = event.clipboardData?.getData("text/plain"); + if (text?.trim()) { + addPromptNodeFromText(text); + } + return; + } + + try { + const text = await navigator.clipboard.readText(); + if (text.trim()) addPromptNodeFromText(text); + } catch { + toast.info("剪贴板没有可粘贴的节点、文本或图片"); + } + } + function undoCanvasChange() { const current = projectRef.current; const previousData = undoStackRef.current.pop(); @@ -532,7 +698,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { x: materialConnection.mouseWorld.x + 64, y: materialConnection.mouseWorld.y - 120, } - : mouseWorld; + : node.position; const nextNode = { ...node, position, @@ -576,7 +742,10 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } async function uploadImage(file: File) { - setError(null); + return uploadImageAt(file, mouseWorld); + } + + async function uploadImageAt(file: File, position: CanvasPosition) { try { const formData = new FormData(); formData.append("image", file); @@ -589,7 +758,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { throw new Error("上传图片失败"); } const naturalSize = await readImageNaturalSize(payload.item.imageUrl); - const baseNode = createImageNodeFromUpload(file, payload.item.imageUrl, mouseWorld); + const baseNode = createImageNodeFromUpload(file, payload.item.imageUrl, position); const node = { ...baseNode, ...getImageNodeDimensions(naturalSize.width, naturalSize.height), @@ -601,8 +770,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { }, }; insertLibraryImageNode(node); + toast.success("图片已上传"); } catch (err) { - setError(err instanceof Error ? err.message : "上传图片失败"); + toast.error(err instanceof Error ? err.message : "上传图片失败"); } } @@ -610,8 +780,32 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { uploadInputRef.current?.click(); } + function addPromptNodeFromText(text: string, position = mouseWorld) { + const trimmedText = text.trim(); + if (!trimmedText) return; + const baseNode = createCanvasNode("prompt", position); + const node: CanvasNodeType = { + ...baseNode, + title: trimmedText.length > 18 ? `${trimmedText.slice(0, 18)}...` : "提示词", + metadata: { + prompt: trimmedText, + }, + }; + + patchProjectData((current) => ({ + ...current, + data: { + ...current.data, + nodes: [...current.data.nodes, node], + }, + })); + setSelectedNodeId(node.id); + setSelectedNodeIds([node.id]); + setSelectedConnectionId(null); + toast.success("已粘贴为提示词节点"); + } + async function copyCanvasJson() { - setError(null); try { const response = await fetch(`/api/canvas/projects/${projectId}/export`, { cache: "no-store", @@ -621,14 +815,14 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { throw new Error(payload.error || "复制 JSON 失败"); } await navigator.clipboard.writeText(JSON.stringify(payload.item, null, 2)); + toast.success("画布 JSON 已复制"); } catch (err) { - setError(err instanceof Error ? err.message : "复制 JSON 失败"); + toast.error(err instanceof Error ? err.message : "复制 JSON 失败"); } } async function importCanvasJson() { if (!importJson.trim() || isImporting) return; - setError(null); setIsImporting(true); try { const parsed = JSON.parse(importJson); @@ -641,16 +835,16 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { if (!response.ok || !payload.item?.id) { throw new Error(payload.error || "导入画布失败"); } + toast.success("画布已导入"); router.push(`/canvas/${payload.item.id}`); } catch (err) { - setError(err instanceof Error ? err.message : "导入画布失败"); + toast.error(err instanceof Error ? err.message : "导入画布失败"); } finally { setIsImporting(false); } } async function replaceNodeImage(nodeId: string, file: File) { - setError(null); try { const formData = new FormData(); formData.append("image", file); @@ -682,8 +876,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { naturalHeight: naturalSize.height, } as CanvasNodeType["metadata"], }); + toast.success("图片已替换"); } catch (err) { - setError(err instanceof Error ? err.message : "替换图片失败"); + toast.error(err instanceof Error ? err.message : "替换图片失败"); } } @@ -697,10 +892,37 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { anchorNodeId, ); if ("error" in resolved) { - setError(resolved.error); + toast.error(resolved.error); return; } + const anchor = + current.data.nodes.find((node) => node.id === anchorNodeId) ?? + resolved.imageNode ?? + resolved.promptNode; + const loadingPosition = placeResultNode(anchor, current.data.nodes, mouseWorld); + const loadingNode = createImageLoadingNode(loadingPosition); + const loadingConnection: CanvasConnection = { + id: crypto.randomUUID(), + type: "generated", + fromNodeId: anchor.id, + toNodeId: loadingNode.id, + }; + const projectWithLoadingNode = { + ...current, + data: { + ...current.data, + nodes: [...current.data.nodes, loadingNode], + connections: [...current.data.connections, loadingConnection], + }, + }; + setProject(projectWithLoadingNode); + projectRef.current = projectWithLoadingNode; + setSelectedNodeId(loadingNode.id); + setSelectedNodeIds([loadingNode.id]); + setIsGenerating(true); + await saveProject(projectWithLoadingNode); + const formData = new FormData(); formData.append("mode", resolved.imageNodes.length ? "edit" : "generate"); formData.append("prompt", resolved.prompt); @@ -721,11 +943,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } } - setError(null); - setIsGenerating(true); + const clientRequestId = createClientRequestId(); + const requestStartedAt = getNowMs(); + + setJob(null); try { - const clientRequestId = createClientRequestId(); - const requestStartedAt = getNowMs(); const response = await fetch("/api/images", { method: "POST", headers: { "x-client-request-id": clientRequestId }, @@ -738,89 +960,238 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const submittedJob = normalizeImageJobPayload(payload); setJob(submittedJob); - if (!submittedJob.jobId) return; + toast.success("图片任务已提交", { + description: submittedJob.jobId + ? `任务 ID:${submittedJob.jobId}` + : undefined, + }); + if (!submittedJob.jobId) { + throw buildGenerationError( + response, + { ...payload, error: "服务端没有返回 jobId" }, + requestStartedAt, + ); + } + writeStoredCanvasJob(projectId, { + jobId: submittedJob.jobId, + loadingNodeId: loadingNode.id, + clientRequestId, + requestStartedAt, + }); - const completedJob = await pollJob(submittedJob.jobId, requestStartedAt); + const completedJob = await watchCanvasJob( + submittedJob.jobId, + requestStartedAt, + clientRequestId, + ); if (completedJob.status === "failed") { throw buildGenerationErrorFromJob(completedJob, requestStartedAt); } if (completedJob.result) { - const latest = projectRef.current; - if (!latest) return; - const anchor = - latest.data.nodes.find((node) => node.id === anchorNodeId) ?? - resolved.imageNode ?? - resolved.promptNode; - const position = placeResultNode(anchor, latest.data.nodes, mouseWorld); - const resultNode = createImageResultNode(completedJob.result, position); - const resultConnection: CanvasConnection = { - id: crypto.randomUUID(), - fromNodeId: anchor.id, - toNodeId: resultNode.id, - }; - const nextProject = { - ...latest, - data: { - ...latest.data, - nodes: [...latest.data.nodes, resultNode], - connections: [...latest.data.connections, resultConnection], - }, - }; - setProject(nextProject); - projectRef.current = nextProject; - setSelectedNodeId(resultNode.id); - setSelectedNodeIds([resultNode.id]); - setHistory((items) => [completedJob.result!, ...items]); - await saveProject(nextProject); + await applyCompletedCanvasJob(completedJob, loadingNode.id); } } catch (err) { - const fallbackStartedAt = getNowMs(); - const fallbackRequestId = createClientRequestId(); const normalizedError = normalizeGenerationError( err, - fallbackStartedAt, - fallbackRequestId, + requestStartedAt, + clientRequestId, ); - setError(normalizedError.message); + const latest = projectRef.current; + if (latest) { + const nextProject = markCanvasJobFailed( + latest, + loadingNode.id, + normalizedError.message, + ); + setProject(nextProject); + projectRef.current = nextProject; + void saveProject(nextProject); + } + toast.error(normalizedError.title, { + description: normalizedError.message, + }); } finally { setIsGenerating(false); + clearStoredCanvasJob(projectId); } } - async function pollJob(jobId: string, requestStartedAt: number) { - for (let attempt = 0; attempt < 120; attempt += 1) { - await wait(attempt < 3 ? 1000 : 2000); - const response = await fetch( - `/api/images?jobId=${encodeURIComponent(jobId)}`, - { cache: "no-store" }, - ); - const payload = await parseJsonResponse(response); - const nextJob = normalizeImageJobPayload(payload); - setJob(nextJob); - if (nextJob.status === "succeeded" || nextJob.status === "failed") { - return nextJob; - } + function watchCanvasJob( + jobId: string, + requestStartedAt: number, + clientRequestId: string, + ) { + jobWatcherCleanupRef.current?.(); + + return new Promise((resolve, reject) => { + jobWatcherCleanupRef.current = watchImageJob({ + jobId, + onJob: setJob, + onComplete: (nextJob) => { + clearStoredCanvasJob(projectId); + jobWatcherCleanupRef.current = null; + resolve(nextJob); + }, + onError: (error) => { + clearStoredCanvasJob(projectId); + jobWatcherCleanupRef.current = null; + reject(normalizeGenerationError(error, requestStartedAt, clientRequestId)); + }, + }); + }); + } + + async function applyCompletedCanvasJob( + completedJob: ImageJobPayload, + loadingNodeId: string, + ) { + if (!completedJob.result) return; + const latest = projectRef.current; + if (!latest) return; + const loadingNode = latest.data.nodes.find((node) => node.id === loadingNodeId); + if (!loadingNode) { + setHistory((items) => [completedJob.result!, ...items]); + return; } - const elapsedMs = Math.round(getNowMs() - requestStartedAt); - throw new Error(`生成超时(${elapsedMs}ms)`); + const resultNode = { + ...createImageResultNode(completedJob.result, loadingNode.position), + id: loadingNodeId, + }; + const nextProject = { + ...latest, + data: { + ...latest.data, + nodes: latest.data.nodes.map((node) => + node.id === loadingNodeId ? resultNode : node, + ), + }, + }; + setProject(nextProject); + projectRef.current = nextProject; + setSelectedNodeId(resultNode.id); + setSelectedNodeIds([resultNode.id]); + setHistory((items) => [completedJob.result!, ...items]); + await saveProject(nextProject); + } + + function resumeCanvasJob(storedJob: StoredCanvasJob) { + setIsGenerating(true); + void watchCanvasJob( + storedJob.jobId, + storedJob.requestStartedAt, + storedJob.clientRequestId, + ) + .then(async (completedJob) => { + if (completedJob.status === "failed") { + throw buildGenerationErrorFromJob( + completedJob, + storedJob.requestStartedAt, + ); + } + await applyCompletedCanvasJob(completedJob, storedJob.loadingNodeId); + }) + .catch((err) => { + const normalizedError = normalizeGenerationError( + err, + storedJob.requestStartedAt, + storedJob.clientRequestId, + ); + const latest = projectRef.current; + if (latest) { + const nextProject = markCanvasJobFailed( + latest, + storedJob.loadingNodeId, + normalizedError.message, + ); + setProject(nextProject); + projectRef.current = nextProject; + void saveProject(nextProject); + } + toast.error(normalizedError.title, { + description: normalizedError.message, + }); + }) + .finally(() => { + setIsGenerating(false); + clearStoredCanvasJob(projectId); + }); + } + + function handleCanvasDragOver(event: ReactDragEvent) { + if (!hasImageFile(event.dataTransfer)) return; + event.preventDefault(); + } + + function handleCanvasDrop(event: ReactDragEvent) { + if (!hasImageFile(event.dataTransfer)) return; + event.preventDefault(); + const file = Array.from(event.dataTransfer.files).find((item) => + item.type.startsWith("image/"), + ); + if (!file) return; + const rect = containerRef.current?.getBoundingClientRect(); + const position = rect + ? screenToWorld( + { x: event.clientX - rect.left, y: event.clientY - rect.top }, + viewport, + ) + : mouseWorld; + void uploadImageAt(file, position); } useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (isTypingTarget(event.target)) return; + const key = event.key.toLowerCase(); + const modifierPressed = event.ctrlKey || event.metaKey; + if (modifierPressed && key === "a") { + event.preventDefault(); + setSelectedNodeIds(nodes.map((node) => node.id)); + setSelectedNodeId(nodes[0]?.id ?? null); + setSelectedConnectionId(null); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + if (selectedNodeIds.length) { + event.preventDefault(); + void copySelectedNodes(); + } + return; + } + if (modifierPressed && key === "v") { + event.preventDefault(); + void pasteFromSystemClipboard(); + return; + } + if (modifierPressed && (event.key === "+" || event.key === "=")) { + event.preventDefault(); + updateViewport({ ...viewport, k: clampScale(viewport.k * 1.18) }); + return; + } + if (modifierPressed && (event.key === "-" || event.key === "_")) { + event.preventDefault(); + updateViewport({ ...viewport, k: clampScale(viewport.k / 1.18) }); + return; + } + if (modifierPressed && event.key === "0") { + event.preventDefault(); + updateViewport({ x: 0, y: 0, k: 1 }); + return; + } if (event.key === "Delete" || event.key === "Backspace") { if (selectedConnectionId) { event.preventDefault(); deleteSelectedConnection(); return; } - if (selectedNodeId) { + if (selectedNodeIds.length) { event.preventDefault(); - deleteNode(selectedNodeId); + deleteSelection(); } + return; } - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") { + if (modifierPressed && key === "z") { event.preventDefault(); if (event.shiftKey) { redoCanvasChange(); @@ -829,29 +1200,42 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } return; } - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") { + if (modifierPressed && key === "y") { event.preventDefault(); redoCanvasChange(); return; } - if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + if (modifierPressed && event.key === "Enter") { event.preventDefault(); void generateFromCanvas(); + return; } if (event.key === "Escape") { + setShowShortcutsDialog(false); + setShowImportDialog(false); setActiveConnection(null); setSelectedConnectionId(null); setSelectionBox(null); } }; + const handlePaste = (event: ClipboardEvent) => { + if (isTypingTarget(event.target)) return; + event.preventDefault(); + void pasteFromSystemClipboard(event); + }; window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener("paste", handlePaste); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("paste", handlePaste); + }; }); if (isLoading) { return (
+ 加载画布中...
); @@ -860,6 +1244,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { if (!project) { return (
+ @@ -868,7 +1253,12 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } return ( -
+
+ updateViewport({ x: 0, y: 0, k: 1 })} onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)} + onShowShortcuts={() => setShowShortcutsDialog(true)} onCopyJson={() => void copyCanvasJson()} onImportJson={() => setShowImportDialog(true)} onUndo={undoCanvasChange} @@ -983,8 +1374,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { connectionTarget={activeConnection?.snapNodeId === node.id} imageReferenceLabel={imageReferenceLabels.get(node.id)} node={node} + promptReferenceLabel={promptReferenceLabels.get(node.id)} promptMentionSources={ - node.type === "prompt" + node.type === "prompt" || node.type === "image" ? getPromptMentionSources(nodes, connections, node.id) : [] } @@ -998,9 +1390,19 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onGenerate={(id) => void generateFromCanvas(id)} onReplaceImage={(id, file) => void replaceNodeImage(id, file)} onResize={(id, width, height) => patchNode(id, { width, height })} - onSelect={(id) => { - setSelectedNodeId(id); - setSelectedNodeIds([id]); + onSelect={(id, additive) => { + if (additive) { + setSelectedNodeIds((current) => { + const nextIds = current.includes(id) + ? current.filter((nodeId) => nodeId !== id) + : [...current, id]; + setSelectedNodeId(nextIds.at(-1) ?? null); + return nextIds; + }); + } else { + setSelectedNodeId(id); + setSelectedNodeIds([id]); + } setSelectedConnectionId(null); }} /> @@ -1024,7 +1426,6 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { void importCanvasJson()} /> + setShowShortcutsDialog(false)} + />
); } @@ -1212,7 +1617,10 @@ function CanvasJsonImportDialog({ - @@ -1221,6 +1629,111 @@ function CanvasJsonImportDialog({ ); } +const SHORTCUT_GROUPS = [ + { + items: [ + { keys: ["拖动画布"], description: "平移视图" }, + { keys: ["Space", "+", "拖动"], description: "临时平移视图" }, + { keys: ["滚轮"], description: "缩放画布" }, + { keys: ["Ctrl / Cmd", "+", "+ / -"], description: "精确调整缩放" }, + { keys: ["Ctrl / Cmd", "+", "0"], description: "重置视图" }, + { keys: ["长按拖动", "/", "框选模式"], description: "框选多个节点" }, + { keys: ["Shift / Ctrl / Cmd", "+", "点击"], description: "追加或取消选择节点" }, + ], + }, + { + items: [ + { keys: ["Ctrl / Cmd", "+", "A"], description: "全选节点" }, + { keys: ["Ctrl / Cmd", "+", "C"], description: "复制选中节点" }, + { keys: ["Ctrl / Cmd", "+", "V"], description: "粘贴节点、文本或图片" }, + { keys: ["Ctrl / Cmd", "+", "Z"], description: "撤销" }, + { keys: ["Ctrl / Cmd", "+", "Shift", "+", "Z"], description: "重做" }, + { keys: ["Ctrl / Cmd", "+", "Y"], description: "重做" }, + { keys: ["Delete / Backspace"], description: "删除选中" }, + { keys: ["Ctrl / Cmd", "+", "Enter"], description: "从当前节点生成" }, + { keys: ["Esc"], description: "取消选择并关闭浮层" }, + { keys: ["拖入图片"], description: "上传到画布" }, + ], + }, +] satisfies Array<{ + items: Array<{ keys: string[]; description: string }>; +}>; + +function ShortcutDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+

快捷键

+ +
+
+
+ {SHORTCUT_GROUPS.map((group, groupIndex) => ( +
+ {group.items.map((item) => ( +
+
+ {item.keys.map((key, index) => + isShortcutSeparator(key) ? ( + + {key} + + ) : ( + + {key} + + ), + )} +
+
+ {item.description} +
+
+ ))} +
+ ))} +
+
+
+
+ ); +} + +function isShortcutSeparator(key: string) { + return key === "+" || key === "/"; +} + async function imageUrlToFile(url: string, filename: string) { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { @@ -1289,14 +1802,147 @@ function inferConnectionType( return undefined; } -function wait(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); +function markCanvasJobFailed( + project: CanvasProjectRecord, + loadingNodeId: string, + message: string, +) { + return { + ...project, + data: { + ...project.data, + nodes: project.data.nodes.map((node) => + node.id === loadingNodeId && node.type === "image" + ? { + ...node, + title: "生成失败", + metadata: { + ...(node.metadata as CanvasImageNodeMetadata), + status: "error", + errorMessage: message, + } satisfies CanvasImageNodeMetadata, + } + : node, + ), + }, + }; +} + +function getCanvasJobStorageKey(projectId: string) { + return `${activeCanvasJobStoragePrefix}${projectId}`; +} + +function readStoredCanvasJob(projectId: string) { + try { + const raw = window.sessionStorage.getItem(getCanvasJobStorageKey(projectId)); + if (!raw) return null; + const payload = JSON.parse(raw) as Partial; + if (!payload.jobId || !payload.loadingNodeId || !payload.clientRequestId) { + return null; + } + + return { + jobId: payload.jobId, + loadingNodeId: payload.loadingNodeId, + clientRequestId: payload.clientRequestId, + requestStartedAt: + typeof payload.requestStartedAt === "number" + ? payload.requestStartedAt + : getNowMs(), + } satisfies StoredCanvasJob; + } catch { + return null; + } +} + +function writeStoredCanvasJob(projectId: string, job: StoredCanvasJob) { + window.sessionStorage.setItem( + getCanvasJobStorageKey(projectId), + JSON.stringify(job), + ); +} + +function clearStoredCanvasJob(projectId: string) { + window.sessionStorage.removeItem(getCanvasJobStorageKey(projectId)); } function getNowMs() { return performance.now(); } +function hasImageFile(dataTransfer: DataTransfer) { + return Array.from(dataTransfer.items).some((item) => + item.type.startsWith("image/"), + ); +} + +async function readNodeClipboard(fallback: CanvasNodeClipboard | null) { + try { + const text = await navigator.clipboard.readText(); + const parsed = JSON.parse(text); + if (isNodeClipboard(parsed)) { + return parsed; + } + } catch { + // Ignore unavailable or unrelated system clipboard content. + } + return fallback; +} + +function isNodeClipboard(value: unknown): value is CanvasNodeClipboard { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + candidate.kind === "imagegen.canvas.nodes" && + candidate.version === 1 && + Array.isArray(candidate.nodes) && + candidate.nodes.length > 0 && + candidate.nodes.every(isClipboardCanvasNode) && + Array.isArray(candidate.connections) && + candidate.connections.every(isClipboardCanvasConnection) + ); +} + +function isClipboardCanvasNode(value: unknown): value is CanvasNodeType { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + (candidate.type === "prompt" || + candidate.type === "config" || + candidate.type === "image") && + typeof candidate.title === "string" && + typeof candidate.width === "number" && + typeof candidate.height === "number" && + Boolean(candidate.position) && + typeof candidate.position?.x === "number" && + typeof candidate.position?.y === "number" && + Boolean(candidate.metadata) + ); +} + +function isClipboardCanvasConnection(value: unknown): value is CanvasConnection { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.fromNodeId === "string" && + typeof candidate.toNodeId === "string" + ); +} + +function cloneCanvasNode(node: CanvasNodeType): CanvasNodeType { + return { + ...node, + position: { ...node.position }, + metadata: { ...node.metadata }, + }; +} + +function cloneCanvasConnection(connection: CanvasConnection): CanvasConnection { + return { ...connection }; +} + function isTypingTarget(target: EventTarget | null) { return ( target instanceof HTMLInputElement || diff --git a/src/components/canvas/canvas-node-inspector.tsx b/src/components/canvas/canvas-node-inspector.tsx index 310a6e5..9110db5 100644 --- a/src/components/canvas/canvas-node-inspector.tsx +++ b/src/components/canvas/canvas-node-inspector.tsx @@ -8,7 +8,7 @@ import { type FormEvent, type KeyboardEvent, } from "react"; -import { ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react"; +import { FileText, ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -20,7 +20,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview"; import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions"; import { cn } from "@/lib/utils"; @@ -45,7 +44,6 @@ type CanvasNodeInspectorProps = { history: HistoryItem[]; isHistoryLoading: boolean; job: ImageJobPayload | null; - error: string | null; showLibrary: boolean; promptMentionSources: PromptMentionSource[]; onPatchNode: (id: string, patch: Partial) => void; @@ -62,7 +60,6 @@ export function CanvasNodeInspector({ history, isHistoryLoading, job, - error, showLibrary, promptMentionSources, onPatchNode, @@ -80,7 +77,7 @@ export function CanvasNodeInspector({ .includes(historyFilter.toLowerCase()), ); - if (!node && !showLibrary && !job?.progressMessage && !error) { + if (!node && !showLibrary && !job?.progressMessage) { return null; } @@ -89,23 +86,6 @@ export function CanvasNodeInspector({ className="pointer-events-none absolute inset-0 z-[85]" data-canvas-ui > -
- {job?.progressMessage || error ? ( - - {job?.progressMessage ? ( -
- {job.progressMessage} -
- ) : null} - {error ? ( -
- {error} -
- ) : null} -
- ) : null} -
-
{node ? ( @@ -277,10 +257,15 @@ function SelectedNodeForm({ /> ) : null} {node.type === "config" ? ( - + ) : null} {node.type === "image" ? ( ) => void; }) { const metadata = node.metadata as CanvasPromptNodeMetadata; + function patchPrompt(nextPrompt: string) { + onPatchNode(node.id, { + metadata: { ...metadata, prompt: nextPrompt }, + }); + } + + return ( + + ); +} + +function MentionPromptEditor({ + emptyState, + mentionHelp, + mentionSources, + placeholder, + value, + onChange, +}: { + emptyState: string; + mentionHelp: string; + mentionSources: PromptMentionSource[]; + placeholder: string; + value: string; + onChange: (value: string) => void; +}) { const editorRef = useRef(null); - const lastPromptRef = useRef(metadata.prompt || ""); const [mentionState, setMentionState] = useState<{ query: string; } | null>(null); @@ -316,66 +333,46 @@ function PromptFields({ : mentionSources; useEffect(() => { - const currentPrompt = metadata.prompt || ""; - if (lastPromptRef.current === currentPrompt) return; - lastPromptRef.current = currentPrompt; - renderPromptEditor(editorRef.current, currentPrompt, mentionSources); - }, [mentionSources, metadata.prompt]); + renderPromptEditor(editorRef.current, value, mentionSources); + }, [mentionSources, value]); - function updateMentionState(value: string) { - const atIndex = value.lastIndexOf("@"); - if (atIndex === -1) { + function updateMentionState(element: HTMLDivElement) { + const activeQuery = getActiveMentionQuery(element); + if (!activeQuery) { setMentionState(null); return; } - if (atIndex > 0 && /\S/.test(value[atIndex - 1] || "")) { - setMentionState(null); - return; - } - const query = value.slice(atIndex + 1); - if (query.includes(" ") || query.includes("\n")) { - setMentionState(null); - return; - } - setMentionState({ - query, - }); - } - - function patchPrompt(nextPrompt: string) { - lastPromptRef.current = nextPrompt; - onPatchNode(node.id, { - metadata: { ...metadata, prompt: nextPrompt }, - }); + setMentionState({ query: activeQuery.query }); } function handleEditorInput(event: FormEvent) { + normalizePromptEditorAliases(event.currentTarget, mentionSources); const nextPrompt = serializePromptEditor(event.currentTarget); event.currentTarget.dataset.renderedPrompt = nextPrompt; - patchPrompt(nextPrompt); - updateMentionState(nextPrompt); + onChange(nextPrompt); + updateMentionState(event.currentTarget); } function insertMention(source: PromptMentionSource) { const element = editorRef.current; if (!element) return; - replaceActiveMentionQuery(element, mentionState?.query ?? "", source); + replaceActiveMentionQuery(element, source); const nextPrompt = serializePromptEditor(element); element.dataset.renderedPrompt = nextPrompt; - patchPrompt(nextPrompt); + onChange(nextPrompt); setMentionState(null); } return (
- +
{ editorRef.current = element; - renderPromptEditor(element, metadata.prompt || "", mentionSources); + renderPromptEditor(element, value, mentionSources); }} className="thin-scrollbar min-h-36 w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400" contentEditable @@ -384,7 +381,7 @@ function PromptFields({ tabIndex={0} onInput={handleEditorInput} onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)} - onKeyUp={(event) => updateMentionState(serializePromptEditor(event.currentTarget))} + onKeyUp={(event) => updateMentionState(event.currentTarget)} onPaste={pastePlainText} onFocus={() => { if (!editorRef.current?.textContent?.trim()) { @@ -395,18 +392,16 @@ function PromptFields({ window.setTimeout(() => setMentionState(null), 120); }} /> - {!(metadata.prompt || "").trim() ? ( + {!value.trim() ? (
- {mentionSources.length - ? "输入提示词,键入 @ 引用已连接素材" - : "输入提示词"} + {placeholder}
) : null}
{mentionState && filteredMentionSources.length > 0 ? (
- 已连接素材 + 已连接素材/文本
{filteredMentionSources.map((source) => ( @@ -426,6 +421,8 @@ function PromptFields({ className="h-full w-full object-cover" src={source.imageUrl} /> + ) : source.kind === "prompt" ? ( + ) : ( )} @@ -450,7 +447,7 @@ function PromptFields({ {mentionSources.length ? (
-
可引用的上游素材
+
可引用的上游素材/文本
{mentionSources.map((source) => (
- 只允许引用已连接到当前提示词节点的图片素材。 + {mentionHelp}
) : (
- 当前提示词节点还没有上游图片素材,连接图片节点后即可使用 `[图1](canvas-image://...)` 引用图片。 + {emptyState}
)}
@@ -528,6 +527,9 @@ function createMentionCard(source: PromptMentionSource) { image.className = "h-full w-full object-cover"; image.src = source.imageUrl; imageWrap.append(image); + } else if (source.kind === "prompt") { + imageWrap.textContent = "文"; + imageWrap.className += " text-[10px] font-semibold text-zinc-600"; } const copy = document.createElement("span"); @@ -543,20 +545,24 @@ function createMentionCard(source: PromptMentionSource) { } function serializePromptEditor(element: HTMLDivElement) { + return serializePromptNode(element); +} + +function serializePromptNode(node: Node): string { let result = ""; - for (const node of element.childNodes) { - if (node.nodeType === Node.TEXT_NODE) { - result += node.textContent || ""; + for (const child of node.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + result += child.textContent || ""; continue; } - if (node instanceof HTMLElement) { - if (node.dataset.mentionToken) { - result += node.dataset.mentionToken; + if (child instanceof HTMLElement) { + if (child.dataset.mentionToken) { + result += child.dataset.mentionToken; continue; } - result += node.innerText || node.textContent || ""; + result += serializePromptNode(child); } } @@ -565,32 +571,124 @@ function serializePromptEditor(element: HTMLDivElement) { function replaceActiveMentionQuery( element: HTMLDivElement, - query: string, source: PromptMentionSource, ) { element.focus(); + const activeQuery = getActiveMentionQuery(element); const selection = window.getSelection(); - const textNode = selection?.anchorNode; - if (!selection || !textNode || textNode.nodeType !== Node.TEXT_NODE) { + const textNode = activeQuery?.textNode; + if (!selection || !activeQuery || !textNode) { element.append(createMentionCard(source), document.createTextNode(" ")); placeCaretAtEnd(element); return; } const text = textNode.textContent || ""; - const cursor = selection.anchorOffset; - const replaceStart = Math.max(0, cursor - query.length - 1); - const before = text.slice(0, replaceStart); - const after = text.slice(cursor); + const before = text.slice(0, activeQuery.start); + const after = text.slice(activeQuery.end); const parent = textNode.parentNode; if (!parent) return; const fragment = document.createDocumentFragment(); if (before) fragment.append(document.createTextNode(before)); - fragment.append(createMentionCard(source), document.createTextNode(" ")); + const card = createMentionCard(source); + const spacer = document.createTextNode(" "); + fragment.append(card, spacer); if (after) fragment.append(document.createTextNode(after)); parent.replaceChild(fragment, textNode); - placeCaretAtEnd(element); + placeCaretAfter(spacer); +} + +function getActiveMentionQuery(element: HTMLDivElement) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) { + return null; + } + if (!element.contains(selection.anchorNode)) return null; + + let textNode = + selection.anchorNode?.nodeType === Node.TEXT_NODE + ? selection.anchorNode + : null; + let offset = selection.anchorOffset; + + if (!textNode && selection.anchorNode instanceof HTMLElement) { + const child = selection.anchorNode.childNodes.item( + Math.max(0, selection.anchorOffset - 1), + ); + if (child?.nodeType === Node.TEXT_NODE) { + textNode = child; + offset = child.textContent?.length ?? 0; + } + } + + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null; + + const text = textNode.textContent || ""; + const beforeCursor = text.slice(0, offset); + const match = /(^|\s)@([^\s@]*)$/.exec(beforeCursor); + if (!match) return null; + + return { + textNode, + query: match[2], + start: beforeCursor.length - match[2].length - 1, + end: offset, + }; +} + +function normalizePromptEditorAliases( + element: HTMLDivElement, + mentionSources: PromptMentionSource[], +) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + let current = walker.nextNode(); + while (current) { + if (!current.parentElement?.closest("[data-mention-token]")) { + textNodes.push(current as Text); + } + current = walker.nextNode(); + } + + for (const textNode of textNodes) { + replaceMentionAliasesInTextNode(textNode, mentionSources); + } +} + +function replaceMentionAliasesInTextNode( + textNode: Text, + mentionSources: PromptMentionSource[], +) { + const text = textNode.textContent || ""; + const sortedSources = [...mentionSources].sort( + (left, right) => right.alias.length - left.alias.length, + ); + let cursor = 0; + let changed = false; + + const fragment = document.createDocumentFragment(); + while (cursor < text.length) { + const matchedSource = sortedSources.find((source) => + text.startsWith(source.alias, cursor), + ); + if (!matchedSource) { + const nextAliasIndex = sortedSources.reduce((nextIndex, source) => { + const index = text.indexOf(source.alias, cursor + 1); + return index === -1 ? nextIndex : Math.min(nextIndex, index); + }, text.length); + fragment.append(document.createTextNode(text.slice(cursor, nextAliasIndex))); + cursor = nextAliasIndex; + continue; + } + + fragment.append(createMentionCard(matchedSource)); + cursor += matchedSource.alias.length; + changed = true; + } + + if (!changed) return; + textNode.replaceWith(fragment); } function handleEditorKeyDown( @@ -632,12 +730,22 @@ function placeCaretAtEnd(element: HTMLElement | null) { selection?.addRange(range); } +function placeCaretAfter(node: Node) { + const range = document.createRange(); + range.setStartAfter(node); + range.setEndAfter(node); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} function ConfigFields({ node, + onGenerateNode, onPatchNode, }: { node: CanvasNode; + onGenerateNode: (id: string) => void; onPatchNode: (id: string, patch: Partial) => void; }) { const metadata = node.metadata as CanvasConfigNodeMetadata; @@ -711,6 +819,7 @@ function ConfigFields({ @@ -719,10 +828,12 @@ function ConfigFields({ } function ImageFields({ + mentionSources, node, onPatchNode, onReplaceNodeImage, }: { + mentionSources: PromptMentionSource[]; node: CanvasNode; onPatchNode: (id: string, patch: Partial) => void; onReplaceNodeImage: (nodeId: string, file: File) => void; @@ -734,11 +845,17 @@ function ImageFields({ return ( <> -