feat(canvas): 支持拖拽连线替换提示词参考图并全量同步提示词引用
This commit is contained in:
@@ -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 ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[var(--z-dialog-popover)] flex select-none items-center gap-1.5 rounded-full border border-white/15 bg-black/60 px-2.5 py-1 text-[11px] font-medium text-white/90 shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-md transition-all"
|
||||
style={{
|
||||
left: connectionReplaceHover.clientX + 14,
|
||||
top: connectionReplaceHover.clientY + 14,
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
<ArrowLeftRight className="size-3 text-blue-400" />
|
||||
<span>松开替换</span>
|
||||
<span className="rounded bg-white/15 px-1.5 py-0.5 text-[10px] font-semibold text-blue-200">
|
||||
@{connectionReplaceHover.referenceLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
|
||||
<CanvasProjectSelectionToolbar
|
||||
anchorRef={selectionBoundsElementRef}
|
||||
|
||||
@@ -34,6 +34,14 @@ type UseCanvasConnectionControllerOptions = {
|
||||
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
|
||||
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
|
||||
setDrawingNodeId: Dispatch<SetStateAction<string | null>>;
|
||||
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<string | null>(null);
|
||||
const [connectionApproach, setConnectionApproach] = useState<CanvasConnectionApproach>(null);
|
||||
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
|
||||
const [connectionReplaceHover, setConnectionReplaceHover] = useState<ConnectionReplaceHover | null>(null);
|
||||
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
|
||||
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
|
||||
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
|
||||
@@ -101,6 +111,28 @@ export function useCanvasConnectionController({
|
||||
const batchConnectionPointerStartRef = useRef<Position | null>(null);
|
||||
const pointerMoveFrameRef = useRef<number | null>(null);
|
||||
const latestPointerMoveRef = useRef<PointerEvent | null>(null);
|
||||
const hoveredReplaceElRef = useRef<HTMLElement | null>(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<HTMLElement>("[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<HTMLElement>(".canvas-node-composer-references");
|
||||
if (refShelf) {
|
||||
const chips = Array.from(refShelf.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-reference-chip]");
|
||||
if (!chip) {
|
||||
const shelf = el?.closest<HTMLElement>(".canvas-node-composer-references");
|
||||
if (shelf) {
|
||||
const chips = Array.from(shelf.querySelectorAll<HTMLElement>("[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,
|
||||
|
||||
@@ -720,6 +720,7 @@ export function useCanvasUpload({
|
||||
openAssetsAtPosition,
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
replaceNodeMedia,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadStatus,
|
||||
|
||||
Reference in New Issue
Block a user