From cd849e1a013c07f230a402afcd8d2e37ee41bdaf Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 1 Mar 2026 01:29:43 +0800 Subject: [PATCH] feat(canvas): add slider compare mode for generated image nodes --- components/canvas/nodes/ImageNode.tsx | 163 +++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 19 deletions(-) diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index da69a651b..ee13f112f 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -257,10 +257,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [draftTitle, setDraftTitle] = useState(""); const [draftNote, setDraftNote] = useState(""); const [isAddingToChat, setIsAddingToChat] = useState(false); + const [isCompareMode, setIsCompareMode] = useState(false); + const [compareSplitPercent, setCompareSplitPercent] = useState(50); + const [isCompareDragging, setIsCompareDragging] = useState(false); // 文件输入的 ref const fileInputRef = useRef(null); const containerRef = useRef(null); + const compareFrameRef = useRef(null); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); @@ -274,12 +278,30 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const isPickerLoading = useCanvasStore((state) => state.isPickerLoading); const setPickerLoading = useCanvasStore((state) => state.setPickerLoading); const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject); + const fallbackCompareSource = useCanvasStore((state) => { + if (data.originalSrc) return ""; + if (!Array.isArray(node.connections) || node.connections.length === 0) return ""; + for (const sourceNodeId of node.connections) { + const sourceNode = state.nodes.find((item) => item.id === sourceNodeId); + if (sourceNode?.type !== "image") continue; + const sourceUrl = String(((sourceNode.data as any) || {}).src || "").trim(); + if (sourceUrl) return sourceUrl; + } + return ""; + }); // 当前节点是否处于拾取模式 const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id; // 当前正在识别的点击位置 const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); + const displayTitle = getImageNodeTitle(data, node.id); + const isMultiSelected = isSelected && selectedNodeIds.length > 1; + const compareSource = String(data.originalSrc || fallbackCompareSource || "").trim(); + const hasCompareSource = + Boolean(data.src) && + Boolean(compareSource) && + compareSource !== data.src; // 退出拾取模式时清空状态 useEffect(() => { @@ -301,9 +323,51 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setTextEditPanelOpen(false); setIsEditingTitle(false); setPromptDialogOpen(false); + setIsCompareMode(false); + setIsCompareDragging(false); } }, [isSelected]); + useEffect(() => { + if (!hasCompareSource || isThisNodePicking) { + setIsCompareMode(false); + setIsCompareDragging(false); + } + }, [hasCompareSource, isThisNodePicking]); + + const updateCompareSplitByClientX = useCallback((clientX: number) => { + const frame = compareFrameRef.current; + if (!frame) return; + + const rect = frame.getBoundingClientRect(); + if (rect.width <= 0) return; + + const rawPercent = ((clientX - rect.left) / rect.width) * 100; + const clampedPercent = Math.max(0, Math.min(100, rawPercent)); + setCompareSplitPercent(clampedPercent); + }, []); + + useEffect(() => { + if (!isCompareDragging) return; + + const handlePointerMove = (event: PointerEvent) => { + updateCompareSplitByClientX(event.clientX); + }; + const handlePointerEnd = () => { + setIsCompareDragging(false); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerEnd); + window.addEventListener("pointercancel", handlePointerEnd); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerEnd); + window.removeEventListener("pointercancel", handlePointerEnd); + }; + }, [isCompareDragging, updateCompareSplitByClientX]); + useEffect(() => { if (!isEditingTitle) { setDraftTitle(getImageNodeTitle(data, node.id)); @@ -392,9 +456,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const handleCancelPicking = useCallback(() => { setPickerMode(false); }, [setPickerMode]); - const displayTitle = getImageNodeTitle(data, node.id); - const isMultiSelected = isSelected && selectedNodeIds.length > 1; - const animateAddToChat = useCallback(async () => { if (!containerRef.current || !data.src || typeof window === "undefined") { return false; @@ -1490,22 +1551,73 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? (
- {displayTitle} { - if (!isThisNodePicking) { - e.stopPropagation(); - setFullscreenPreviewOpen(true); - } - }} - /> + {isCompareMode && hasCompareSource ? ( +
+ {displayTitle} +
+ {`${displayTitle}-原图`} +
+
+ +
+ 原图 +
+
+ 新图 +
+
+ ) : ( + {displayTitle} { + if (!isThisNodePicking) { + e.stopPropagation(); + setFullscreenPreviewOpen(true); + } + }} + /> + )} {/* 🆕 拾取模式:当前点击位置(正在识别) */} {isThisNodePicking && currentClick && ( @@ -1541,6 +1653,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
)} + + {isSelected && !isMultiSelected && hasCompareSource && !isThisNodePicking && ( + + )}
) : data.status === 'generating' ? (