feat(canvas): add slider compare mode for generated image nodes
This commit is contained in:
parent
1625b59e43
commit
cd849e1a01
@ -257,10 +257,14 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const [draftTitle, setDraftTitle] = useState("");
|
const [draftTitle, setDraftTitle] = useState("");
|
||||||
const [draftNote, setDraftNote] = useState("");
|
const [draftNote, setDraftNote] = useState("");
|
||||||
const [isAddingToChat, setIsAddingToChat] = useState(false);
|
const [isAddingToChat, setIsAddingToChat] = useState(false);
|
||||||
|
const [isCompareMode, setIsCompareMode] = useState(false);
|
||||||
|
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
|
||||||
|
const [isCompareDragging, setIsCompareDragging] = useState(false);
|
||||||
|
|
||||||
// 文件输入的 ref
|
// 文件输入的 ref
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const compareFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
@ -274,12 +278,30 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
||||||
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
||||||
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
|
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 isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
|
||||||
|
|
||||||
// 当前正在识别的点击位置
|
// 当前正在识别的点击位置
|
||||||
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
@ -301,9 +323,51 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
setTextEditPanelOpen(false);
|
setTextEditPanelOpen(false);
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
setPromptDialogOpen(false);
|
setPromptDialogOpen(false);
|
||||||
|
setIsCompareMode(false);
|
||||||
|
setIsCompareDragging(false);
|
||||||
}
|
}
|
||||||
}, [isSelected]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isEditingTitle) {
|
if (!isEditingTitle) {
|
||||||
setDraftTitle(getImageNodeTitle(data, node.id));
|
setDraftTitle(getImageNodeTitle(data, node.id));
|
||||||
@ -392,9 +456,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const handleCancelPicking = useCallback(() => {
|
const handleCancelPicking = useCallback(() => {
|
||||||
setPickerMode(false);
|
setPickerMode(false);
|
||||||
}, [setPickerMode]);
|
}, [setPickerMode]);
|
||||||
const displayTitle = getImageNodeTitle(data, node.id);
|
|
||||||
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
|
|
||||||
|
|
||||||
const animateAddToChat = useCallback(async () => {
|
const animateAddToChat = useCallback(async () => {
|
||||||
if (!containerRef.current || !data.src || typeof window === "undefined") {
|
if (!containerRef.current || !data.src || typeof window === "undefined") {
|
||||||
return false;
|
return false;
|
||||||
@ -1490,22 +1551,73 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<img
|
{isCompareMode && hasCompareSource ? (
|
||||||
src={data.src}
|
<div
|
||||||
alt={displayTitle}
|
ref={compareFrameRef}
|
||||||
className={cn(
|
className="relative h-full w-full overflow-hidden bg-muted/30 select-none"
|
||||||
"w-full h-full object-contain bg-muted/30",
|
>
|
||||||
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
<img
|
||||||
)}
|
src={data.src}
|
||||||
draggable={false}
|
alt={displayTitle}
|
||||||
onClick={isThisNodePicking ? handlePickerClick : undefined}
|
className="h-full w-full object-contain"
|
||||||
onDoubleClick={(e) => {
|
draggable={false}
|
||||||
if (!isThisNodePicking) {
|
/>
|
||||||
e.stopPropagation();
|
<div
|
||||||
setFullscreenPreviewOpen(true);
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
}
|
style={{
|
||||||
}}
|
clipPath: `inset(0 ${100 - compareSplitPercent}% 0 0)`,
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={compareSource}
|
||||||
|
alt={`${displayTitle}-原图`}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 z-20 w-0.5 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.25)]"
|
||||||
|
style={{ left: `${compareSplitPercent}%`, transform: "translateX(-50%)" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1/2 z-30 -translate-y-1/2 rounded-full border border-white/70 bg-black/65 px-2 py-1 text-[10px] text-white"
|
||||||
|
style={{ left: `${compareSplitPercent}%`, transform: "translate(-50%, -50%)" }}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsCompareDragging(true);
|
||||||
|
updateCompareSplitByClientX(event.clientX);
|
||||||
|
}}
|
||||||
|
title="拖动中线查看对比"
|
||||||
|
>
|
||||||
|
对比
|
||||||
|
</button>
|
||||||
|
<div className="pointer-events-none absolute left-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
|
||||||
|
原图
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
|
||||||
|
新图
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={data.src}
|
||||||
|
alt={displayTitle}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-full object-contain bg-muted/30",
|
||||||
|
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
||||||
|
)}
|
||||||
|
draggable={false}
|
||||||
|
onClick={isThisNodePicking ? handlePickerClick : undefined}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (!isThisNodePicking) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFullscreenPreviewOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
|
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
|
||||||
{isThisNodePicking && currentClick && (
|
{isThisNodePicking && currentClick && (
|
||||||
@ -1541,6 +1653,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSelected && !isMultiSelected && hasCompareSource && !isThisNodePicking && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute bottom-2 right-2 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-black/80"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsCompareMode((prev) => !prev);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCompareMode ? "退出对比" : "对比显示"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : data.status === 'generating' ? (
|
) : data.status === 'generating' ? (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
|
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user