From f4306676f0d79a1bc1deb6d3f3d6ce82d3c31840 Mon Sep 17 00:00:00 2001 From: Danborad Date: Fri, 11 Sep 2026 19:16:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=94=AF=E6=8C=81=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E8=BF=9E=E7=BA=BF=E6=9B=BF=E6=8D=A2=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=8F=82=E8=80=83=E5=9B=BE=E5=B9=B6=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-node-prompt-panel.tsx | 110 +++++++++++++--- .../lib/canvas/canvas-resource-references.ts | 32 ++++- web/src/pages/canvas/project.tsx | 104 ++++++++++++++- .../use-canvas-connection-controller.ts | 124 +++++++++++++++++- web/src/pages/canvas/use-canvas-upload.ts | 1 + web/src/styles/globals.css | 9 ++ web/test/canvas-resource-references.test.ts | 46 ++++++- 7 files changed, 400 insertions(+), 26 deletions(-) diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 095fe74f0..0c3c2f26c 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,7 +1,7 @@ import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; +import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -43,6 +43,8 @@ type CanvasNodePromptPanelProps = { mentionReferences?: CanvasResourceReference[]; onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void; onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void; + onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void; + onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void; onClose?: () => void; onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; onImageSettingsOpenChange?: (open: boolean) => void; @@ -61,7 +63,7 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12; const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; const PROMPT_EDITOR_MAX_LINES = 8; -export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onReorderReferences, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { +export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; @@ -103,6 +105,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), + transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, @@ -438,11 +441,14 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan <>
onRemoveReference?.(node.id, reference)} onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined} + onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined} + onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined} /> onReplaceReferenceFiles(node.id, reference, files) : undefined} onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight} containerClassName="min-h-0 flex-1" className={expanded @@ -627,10 +634,29 @@ function referenceShelfHeading(references: CanvasResourceReference[]) { return `${label} · ${references.length}`; } -function ConnectedReferenceShelf({ references, theme, onInsert, onRemove, onReorder }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void; onRemove?: (reference: CanvasResourceReference) => void; onReorder?: (orderedNodeIds: string[]) => void }) { +function ConnectedReferenceShelf({ + targetNodeId, + references, + theme, + onInsert, + onRemove, + onReorder, + onReplaceReference, + onReplaceReferenceFiles, +}: { + targetNodeId?: string; + references: CanvasResourceReference[]; + theme: CanvasTheme; + onInsert: (reference: CanvasResourceReference) => void; + onRemove?: (reference: CanvasResourceReference) => void; + onReorder?: (orderedNodeIds: string[]) => void; + onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void; + onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void; +}) { const activeReferences = references.filter((item) => item.active && item.kind !== "skill"); const [imagePreview, setImagePreview] = useState(null); const [draggedReferenceId, setDraggedReferenceId] = useState(null); + const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null); if (!activeReferences.length) return null; const moveReference = (sourceId: string, targetId: string) => { @@ -657,21 +683,72 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove, onReor
{activeReferences.map((reference, index) => { const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video"); + const isDropTarget = dropTargetReferenceId === reference.id; return ( { - if (!onReorder || !draggedReferenceId) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; + if (draggedReferenceId) { + if (!onReorder) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + return; + } + const hasImageNode = event.dataTransfer.types.includes("application/x-canvas-image-node-id"); + const hasFiles = event.dataTransfer.types.includes("Files"); + if (hasImageNode || hasFiles) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + if (dropTargetReferenceId !== reference.id) { + setDropTargetReferenceId(reference.id); + } + } + }} + onDragLeave={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + if (dropTargetReferenceId === reference.id) { + setDropTargetReferenceId(null); + } + } }} onDrop={(event) => { - event.preventDefault(); - const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain"); - setDraggedReferenceId(null); - moveReference(sourceId, reference.nodeId); + if (dropTargetReferenceId === reference.id) { + setDropTargetReferenceId(null); + } + if (draggedReferenceId) { + event.preventDefault(); + const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain"); + setDraggedReferenceId(null); + moveReference(sourceId, reference.nodeId); + return; + } + const sourceNodeId = event.dataTransfer.getData("application/x-canvas-image-node-id"); + if (sourceNodeId && sourceNodeId !== reference.nodeId && onReplaceReference) { + event.preventDefault(); + event.stopPropagation(); + onReplaceReference(reference, sourceNodeId); + return; + } + const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + if (files.length && onReplaceReferenceFiles) { + event.preventDefault(); + event.stopPropagation(); + onReplaceReferenceFiles(reference, files); + return; + } }} > {onReorder ? ( @@ -875,6 +952,7 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo size: node.metadata?.size, quality: node.metadata?.quality, transparentBackground: node.metadata?.transparentBackground, + videoWatermark: node.metadata?.watermark, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count), } : { @@ -898,18 +976,18 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo return { ...globalConfig, model, - quality: defaults.quality || globalConfig.quality || defaultConfig.quality, + quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality, size: defaults.size ?? globalConfig.size ?? defaultConfig.size, - transparentBackground: defaults.transparentBackground || "false", + transparentBackground: defaults.transparentBackground ?? "false", videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality), - videoGenerateAudio: defaults.videoGenerateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, - videoWatermark: defaults.videoWatermark || globalConfig.videoWatermark || defaultConfig.videoWatermark, + videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio, + videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions, - count: defaults.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), + count: defaults.count ?? String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), }; } diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index 672e0bb45..372b3392f 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -215,7 +215,7 @@ function canvasReferenceIdentityChanged(previousReferences: CanvasResourceRefere return previousReferences.some((reference) => nextLabelByNodeId.get(reference.nodeId) !== reference.label); } -function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) { +export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) { const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content)); const promptTemplateMetadata = node.metadata?.promptTemplateOperation ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } @@ -232,7 +232,7 @@ function removeCanvasMentionToken(value: string, token: string) { return replaceCanvasMentionToken(value, token, ""); } -function replaceCanvasMentionToken(value: string, token: string, replacement: string) { +export function replaceCanvasMentionToken(value: string, token: string, replacement: string) { if (!token) return value; const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Numbered media mentions can touch Chinese prose or another mention, but not a longer number. @@ -242,6 +242,34 @@ function replaceCanvasMentionToken(value: string, token: string, replacement: st return value.replace(new RegExp(`${escapedToken}${boundary}`, "gu"), replacement); } +/** + * 替换提示词中针对某参考对象的所有引用(包含 @图片1、@原标题、@[node:xxx])。 + * 只要提示词中存在指向该对象的标记,同步全部替换为新标记。 + */ +export function replaceCanvasReferenceMentions( + prompt: string, + oldReference: { label?: string; title?: string; nodeId?: string }, + replacementToken: string, + replacementTitle?: string, +): string { + let result = prompt; + const cleanToken = replacementToken.startsWith("@") ? replacementToken : `@${replacementToken}`; + const cleanOldLabel = oldReference.label?.replace(/^@/, ""); + if (cleanOldLabel) { + result = replaceCanvasMentionToken(result, `@${cleanOldLabel}`, cleanToken); + } + const cleanOldTitle = oldReference.title?.replace(/^@/, ""); + if (cleanOldTitle && cleanOldTitle !== cleanOldLabel) { + const cleanRepTitle = replacementTitle?.replace(/^@/, ""); + const targetTitleToken = cleanRepTitle ? `@${cleanRepTitle}` : cleanToken; + result = replaceCanvasMentionToken(result, `@${cleanOldTitle}`, targetTitleToken); + } + if (oldReference.nodeId) { + result = replaceCanvasMentionToken(result, canvasNodeMentionToken(oldReference.nodeId), cleanToken); + } + return result; +} + function compactRemovedCanvasMentionPrompt(value: string) { return value .replace(/[ \t]+\n/g, "\n") diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index d6ef927bf..792a933f5 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -28,6 +28,7 @@ import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { App, Button } from "antd"; +import { ArrowLeftRight } from "lucide-react"; import { AppModal } from "@/components/ui/product/app-modal"; import { getNodeSpec } from "@/constant/canvas"; import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer"; @@ -74,7 +75,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel"; import { useFocusMode } from "@/hooks/use-focus-mode"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; -import { applyCanvasConnectionPromptSync, getContextResourceNodes, normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { applyCanvasConnectionPromptSync, buildCanvasNodeMentionReferenceMap, getContextResourceNodes, normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, replaceCanvasReferenceMentions, writeCanvasNodePrompt, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; @@ -726,6 +727,7 @@ function InfiniteCanvasPage() { openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, + replaceNodeMedia, startUploadStatus, uploadModalOpen, uploadTimelineMedia, @@ -955,12 +957,84 @@ function InfiniteCanvasPage() { onNodesDeleted: handleNodesDeleted, }); + const handleReplaceNodeReference = useCallback(( + targetNodeId: string, + oldReference: { id: string; nodeId?: string; label?: string; title?: string }, + sourceNodeId: string, + ) => { + const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId); + if (!sourceNode || sourceNode.id === oldReference.nodeId) return; + + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + + const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId; + const receiverId = configNodeId || targetNodeId; + + let rewired = false; + const nextConnections = previousConnections.map((c) => { + if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) { + rewired = true; + return { ...c, fromNodeId: sourceNodeId }; + } + return c; + }); + + if (!rewired) { + nextConnections.push({ + id: nanoid(), + fromNodeId: sourceNodeId, + toNodeId: receiverId, + }); + } + + const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes); + const targetNextReferences = nextReferencesMap.get(targetNodeId) || []; + const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId); + + const targetNode = previousNodes.find((n) => n.id === targetNodeId); + let nextNodes = previousNodes; + if (targetNode && newRef) { + const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? ""; + const replacementToken = `@${newRef.label}`; + const updatedPrompt = replaceCanvasReferenceMentions( + currentPrompt, + oldReference, + replacementToken, + sourceNode.title ? sourceNode.title : newRef.label, + ); + + nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n)); + } + + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`); + }, [connectionsRef, message, nodesRef, setConnections, setNodes]); + + const handleReplaceNodeReferenceFiles = useCallback(( + _targetNodeId: string, + oldReference: { id: string; nodeId?: string; label?: string; title?: string }, + files: File[], + ) => { + const file = files.find((f) => f.type.startsWith("image/")); + if (!file || !oldReference.nodeId) return; + void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => { + if (success) { + message.success("参考图片已替换"); + } + }); + }, [message, replaceNodeMedia]); + const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectionApproach, + connectionReplaceHover, connectingParams, createConnectedNode, getConnectionCreateDisabledReason, @@ -988,6 +1062,7 @@ function InfiniteCanvasPage() { setContextMenu, setDialogNodeId, setDrawingNodeId, + onReplaceReference: handleReplaceNodeReference, }); const batchSourceNodeIds = useMemo(() => nodes @@ -1953,6 +2028,8 @@ function InfiniteCanvasPage() { onGenerate={handleGenerateNode} onRemoveReference={handleRemoveNodeReference} onReorderReferences={handleReorderNodeReferences} + onReplaceReference={handleReplaceNodeReference} + onReplaceReferenceFiles={handleReplaceNodeReferenceFiles} onClose={() => setDialogNodeId(null)} onNodeMouseDown={handleNodeMouseDown} workspaceMode={workspaceMode} @@ -1963,7 +2040,7 @@ function InfiniteCanvasPage() { /> ); }, - [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, handleReorderNodeReferences, mentionReferencesByNodeId, projectId, runningNodeId, skillMentionReferences, workspaceMode], + [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, handleReorderNodeReferences, handleReplaceNodeReference, handleReplaceNodeReferenceFiles, mentionReferencesByNodeId, projectId, runningNodeId, skillMentionReferences, workspaceMode], ); const renderCanvasNodeContent = useCallback( @@ -2400,9 +2477,9 @@ function InfiniteCanvasPage() { onReloadResource={reloadCanvasNodeResource} onOpenTaskDetails={openCanvasNodeTaskDetails} onOpenVersions={openCanvasNodeVersions} - onViewImage={viewCanvasNodeImage} - onReplaceMedia={replaceCanvasNodeMedia} - onOpenTextEditor={openTextNodeEditor} + onViewImage={viewCanvasNodeImage} + onReplaceMedia={replaceCanvasNodeMedia} + onOpenTextEditor={openTextNodeEditor} onOpenDirector={editCanvasDirector} onOpenDrawing={openDrawingNode} onStartBatchConnection={startBatchConnection} @@ -2591,6 +2668,23 @@ function InfiniteCanvasPage() { /> ) : null} + {connectionReplaceHover ? ( +
+ + 松开替换 + + @{connectionReplaceHover.referenceLabel} + +
+ ) : null} + {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? ( >; setDialogNodeId: Dispatch>; setDrawingNodeId: Dispatch>; + onReplaceReference?: (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => void; +}; + +export type ConnectionReplaceHover = { + targetNodeId: string; + referenceLabel: string; + clientX: number; + clientY: number; }; type ConnectionDropTarget = { @@ -81,6 +89,7 @@ export function useCanvasConnectionController({ setContextMenu, setDialogNodeId, setDrawingNodeId, + onReplaceReference, }: UseCanvasConnectionControllerOptions) { const { message } = App.useApp(); const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey); @@ -89,6 +98,7 @@ export function useCanvasConnectionController({ const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); const [connectionApproach, setConnectionApproach] = useState(null); const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState(); + const [connectionReplaceHover, setConnectionReplaceHover] = useState(null); const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); const [batchConnectionPreview, setBatchConnectionPreview] = useState(null); const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); @@ -101,6 +111,28 @@ export function useCanvasConnectionController({ const batchConnectionPointerStartRef = useRef(null); const pointerMoveFrameRef = useRef(null); const latestPointerMoveRef = useRef(null); + const hoveredReplaceElRef = useRef(null); + + const updateConnectionReplaceHover = useCallback((element: HTMLElement | null, clientX = 0, clientY = 0) => { + if (hoveredReplaceElRef.current === element) { + if (element) { + setConnectionReplaceHover((prev) => prev ? { ...prev, clientX, clientY } : null); + } + return; + } + if (hoveredReplaceElRef.current) { + hoveredReplaceElRef.current.removeAttribute("data-connection-hover-replace"); + } + hoveredReplaceElRef.current = element; + if (element) { + element.setAttribute("data-connection-hover-replace", "true"); + const targetNodeId = element.getAttribute("data-target-node-id") || ""; + const referenceLabel = element.getAttribute("data-reference-label") || "参考图"; + setConnectionReplaceHover({ targetNodeId, referenceLabel, clientX, clientY }); + } else { + setConnectionReplaceHover(null); + } + }, []); useLayoutEffect(() => { connectingParamsRef.current = connectingParams; @@ -123,13 +155,14 @@ export function useCanvasConnectionController({ connectingParamsRef.current = next; setConnectingParams(next); if (!next) { + updateConnectionReplaceHover(null); setConnectionApproach(null); connectingPointerIdRef.current = null; connectingPointerStartRef.current = null; setConnectionTargetNodeId(null); setConnectionTargetAnchorRatio(undefined); } - }, []); + }, [updateConnectionReplaceHover]); const closeConnectionCreateMenu = useCallback(() => { pendingConnectionCreateRef.current = null; @@ -545,9 +578,69 @@ export function useCanvasConnectionController({ }, [finishBatchConnection, message]); const finishConnection = useCallback((clientX: number, clientY: number) => { + updateConnectionReplaceHover(null); if (pendingConnectionCreateRef.current) return; const currentConnection = connectingParamsRef.current; if (!currentConnection) return; + + // 1. 如果是从输出端(source)拖出的连线,检查是否拖到了参考图卡片或参考区上进行替换 + if (currentConnection.handleType === "source" && typeof document !== "undefined") { + const hitElement = document.elementFromPoint(clientX, clientY); + if (hitElement) { + // A) 直接释放在提示词面板的参考图卡片上 + const refChip = hitElement.closest("[data-reference-chip]"); + if (refChip) { + const targetNodeId = refChip.getAttribute("data-target-node-id"); + const oldNodeId = refChip.getAttribute("data-reference-node-id"); + const oldLabel = refChip.getAttribute("data-reference-label") || ""; + const oldRefId = refChip.getAttribute("data-reference-id") || ""; + const oldTitle = refChip.getAttribute("data-reference-title") || oldLabel; + if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) { + if (oldNodeId === currentConnection.nodeId) { + setConnecting(null); + return; + } + onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId); + setConnecting(null); + return; + } + } + + // B) 释放在提示词参考架容器内(自动就近匹配最近的参考卡片) + const refShelf = hitElement.closest(".canvas-node-composer-references"); + if (refShelf) { + const chips = Array.from(refShelf.querySelectorAll("[data-reference-chip]")); + let closestChip: HTMLElement | null = null; + let minDistance = Number.POSITIVE_INFINITY; + chips.forEach((chip) => { + const rect = chip.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const dist = Math.abs(clientX - centerX); + if (dist < minDistance) { + minDistance = dist; + closestChip = chip; + } + }); + if (closestChip) { + const targetNodeId = (closestChip as HTMLElement).getAttribute("data-target-node-id"); + const oldNodeId = (closestChip as HTMLElement).getAttribute("data-reference-node-id"); + const oldLabel = (closestChip as HTMLElement).getAttribute("data-reference-label") || ""; + const oldRefId = (closestChip as HTMLElement).getAttribute("data-reference-id") || ""; + const oldTitle = (closestChip as HTMLElement).getAttribute("data-reference-title") || oldLabel; + if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) { + if (oldNodeId === currentConnection.nodeId) { + setConnecting(null); + return; + } + onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId); + setConnecting(null); + return; + } + } + } + } + } + const dropTarget = getConnectionDropTarget(clientX, clientY, currentConnection); if (dropTarget.nodeId) { connectNodes(currentConnection, dropTarget.nodeId, dropTarget.handleId, dropTarget.anchorRatio); @@ -561,7 +654,7 @@ export function useCanvasConnectionController({ pendingConnectionCreateRef.current = pending; setPendingConnectionCreate(pending); } - }, [connectNodes, getConnectionDropTarget, screenToCanvas, setConnecting]); + }, [connectNodes, getConnectionDropTarget, onReplaceReference, screenToCanvas, setConnecting, updateConnectionReplaceHover]); const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => { event.preventDefault(); @@ -606,6 +699,30 @@ export function useCanvasConnectionController({ } const current = connectingParamsRef.current; if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return; + if (current.handleType === "source" && typeof document !== "undefined") { + const el = document.elementFromPoint(event.clientX, event.clientY); + let chip = el?.closest("[data-reference-chip]"); + if (!chip) { + const shelf = el?.closest(".canvas-node-composer-references"); + if (shelf) { + const chips = Array.from(shelf.querySelectorAll("[data-reference-chip]")); + let closestChip: HTMLElement | null = null; + let minDistance = Number.POSITIVE_INFINITY; + chips.forEach((c) => { + const rect = c.getBoundingClientRect(); + const dist = Math.abs(event.clientX - (rect.left + rect.width / 2)); + if (dist < minDistance) { + minDistance = dist; + closestChip = c; + } + }); + chip = closestChip; + } + } + updateConnectionReplaceHover(chip || null, event.clientX, event.clientY); + } else { + updateConnectionReplaceHover(null); + } const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current); const point = screenToCanvas(event.clientX, event.clientY); setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point)); @@ -662,6 +779,7 @@ export function useCanvasConnectionController({ }; const handlePointerCancel = (event: PointerEvent) => { cancelPendingPointerMove(); + updateConnectionReplaceHover(null); if (batchConnectionPointerIdRef.current === event.pointerId) { clearBatchConnection(); return; @@ -670,6 +788,7 @@ export function useCanvasConnectionController({ }; const cancel = () => { cancelPendingPointerMove(); + updateConnectionReplaceHover(null); if (connectingParamsRef.current) setConnecting(null); if (batchConnectionPreviewRef.current) clearBatchConnection(); }; @@ -692,6 +811,7 @@ export function useCanvasConnectionController({ connectionTargetNodeId, connectionApproach, connectionTargetAnchorRatio, + connectionReplaceHover, connectingParams, createConnectedNode, getConnectionCreateDisabledReason, diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 780a07c84..b98db81fe 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -720,6 +720,7 @@ export function useCanvasUpload({ openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, + replaceNodeMedia, startUploadStatus, uploadModalOpen, uploadStatus, diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index fdd4e5e39..75102f8fd 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -9425,6 +9425,15 @@ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent); } + .canvas-node-reference-chip[data-connection-hover-replace="true"] { + position: relative; + outline: 2px solid #3b82f6 !important; + outline-offset: 1px !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25), 0 4px 16px rgba(0, 0, 0, 0.4) !important; + transition: box-shadow 0.15s ease, outline-color 0.15s ease !important; + z-index: 50 !important; + } + .canvas-node-reference-chip[data-dragging="true"] { opacity: 0.58; } diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts index 514b73699..97f99bf76 100644 --- a/web/test/canvas-resource-references.test.ts +++ b/web/test/canvas-resource-references.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections, reorderCanvasResourceConnections } from "../src/lib/canvas/canvas-resource-references"; +import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections, reorderCanvasResourceConnections, replaceCanvasMentionToken, replaceCanvasReferenceMentions } from "../src/lib/canvas/canvas-resource-references"; import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset"; import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; @@ -383,3 +383,47 @@ describe("reorder canvas resource connections", () => { expect(nextConnections[2]).toBe(unrelated); }); }); + +describe("replace canvas reference mentions", () => { + test("替换参考图时将提示词内所有提及同张图片的标记全部替换", () => { + const oldRef: CanvasResourceReference = { + id: "node-old", + nodeId: "node-old", + kind: "image", + label: "图片1", + title: "卡通可爱动画风画册", + active: true, + }; + const prompt = "@图片1 参考 @图片2 生成,保持 @图片1 的色调与 @卡通可爱动画风画册 的画风"; + const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "@图片3", "未来赛博超跑"); + expect(replaced).toBe("@图片3 参考 @图片2 生成,保持 @图片3 的色调与 @未来赛博超跑 的画风"); + }); + + test("不会误替换较长数字或无关标记", () => { + const oldRef: CanvasResourceReference = { + id: "node-old", + nodeId: "node-old", + kind: "image", + label: "图片1", + title: "画册", + active: true, + }; + const prompt = "@图片1 和 @图片10 及 @图片11,不要改动 @图片10"; + const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "@图片9"); + expect(replaced).toBe("@图片9 和 @图片10 及 @图片11,不要改动 @图片10"); + }); + + test("自动规范化带有或缺失@前缀的参数", () => { + const oldRef: CanvasResourceReference = { + id: "node-old", + nodeId: "node-old", + kind: "image", + label: "@图片1", + title: "@画册", + active: true, + }; + const prompt = "@图片1 的画风结合 @画册 的色调"; + const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "图片2", "新封面"); + expect(replaced).toBe("@图片2 的画风结合 @新封面 的色调"); + }); +});