fix: align image comparison sliders

This commit is contained in:
134 2026-06-30 17:27:17 +08:00
parent e7cd3d6e73
commit b87b44c7b4
2 changed files with 148 additions and 88 deletions

View File

@ -2336,15 +2336,38 @@ export function ImageNode({
? agentExecutionSnapshotId.slice(0, 8)
: "";
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSource = useMemo(() => {
const compareSourceInfo = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
if (!targetIdentity) return "";
if (!targetIdentity) return { url: "", width: 0, height: 0 };
const candidates: string[] = [];
const pushCandidate = (value: unknown) => {
const candidates: Array<{ height: number; url: string; width: number }> = [];
const resolveCandidateResolution = (
sourceNode?: CanvasNode | null,
sourceData?: Partial<ImageNodeData> | null
) => {
const metadata = sourceData?.metadata || {};
const metadataWidth = Number((metadata as any).originalWidth || 0);
const metadataHeight = Number((metadata as any).originalHeight || 0);
const nodeWidth = Number(sourceNode?.size?.width || 0);
const nodeHeight = Number(sourceNode?.size?.height || 0);
const width = metadataWidth > 0 ? metadataWidth : nodeWidth;
const height = metadataHeight > 0 ? metadataHeight : nodeHeight;
return {
width: Number.isFinite(width) && width > 0 ? width : 0,
height: Number.isFinite(height) && height > 0 ? height : 0,
};
};
const pushCandidate = (
value: unknown,
sourceNode?: CanvasNode | null,
sourceData?: Partial<ImageNodeData> | null
) => {
const raw = String(value || "").trim();
if (!raw) return;
candidates.push(raw);
candidates.push({
url: raw,
...resolveCandidateResolution(sourceNode, sourceData),
});
};
const visited = new Set<string>([node.id]);
@ -2360,9 +2383,9 @@ export function ImageNode({
const sourceNode = allNodes.find((item) => item.id === sourceNodeId);
if (!sourceNode || sourceNode.type !== "image") continue;
const sourceData = (sourceNode.data as any) || {};
pushCandidate(sourceData.src);
pushCandidate(sourceData.originalSrc);
pushCandidate(sourceData.metadata?.compareSourceUrl);
pushCandidate(sourceData.src, sourceNode, sourceData);
pushCandidate(sourceData.originalSrc, sourceNode, sourceData);
pushCandidate(sourceData.metadata?.compareSourceUrl, sourceNode, sourceData);
if (
Array.isArray(sourceNode.connections) &&
@ -2372,18 +2395,18 @@ export function ImageNode({
}
}
pushCandidate(data.originalSrc);
pushCandidate(data.metadata?.compareSourceUrl);
pushCandidate(data.originalSrc, node, data);
pushCandidate(data.metadata?.compareSourceUrl, node, data);
for (const candidate of candidates) {
const identity = normalizeImageIdentityUrl(candidate);
const identity = normalizeImageIdentityUrl(candidate.url);
if (!identity) continue;
if (identity !== targetIdentity) {
return candidate;
}
}
return "";
return { url: "", width: 0, height: 0 };
}, [
allNodes,
data.metadata,
@ -2391,9 +2414,33 @@ export function ImageNode({
data.src,
node.connections,
node.id,
node.size.height,
node.size.width,
]);
const compareSource = compareSourceInfo.url;
const hasCompareSource = Boolean(data.src) && Boolean(compareSource);
const compareImageFrameStyle = useMemo<React.CSSProperties>(() => {
const boxWidth = Math.max(1, Number(node.size.width || 0));
const boxHeight = Math.max(1, Number(node.size.height || 0));
const naturalWidth = Math.max(1, Number(compareSourceInfo.width || boxWidth));
const naturalHeight = Math.max(1, Number(compareSourceInfo.height || boxHeight));
const scale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
const renderedWidth = Math.max(1, naturalWidth * scale);
const renderedHeight = Math.max(1, naturalHeight * scale);
return {
height: renderedHeight,
left: (boxWidth - renderedWidth) / 2,
top: (boxHeight - renderedHeight) / 2,
width: renderedWidth,
};
}, [
compareSourceInfo.height,
compareSourceInfo.width,
node.size.height,
node.size.width,
]);
const compareButtonVisualSize = useMemo(() => {
const minSide = Math.max(
1,
@ -8401,66 +8448,77 @@ export function ImageNode({
onPointerMove={(event) => {
updateCompareSplitByClientX(event.clientX);
}}
ref={compareFrameRef}
style={{ touchAction: "none" }}
>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - compareSplitPercent}% 0 0)`,
}}
className="absolute overflow-hidden bg-slate-50 dark:bg-zinc-900"
ref={compareFrameRef}
style={compareImageFrameStyle}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={displayTitle}
className="absolute inset-0 h-full w-full object-contain"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
style={activeImageAdjustmentsStyle}
src={displayImageSrc}
/>
{renderImageAdjustmentOverlays()}
</div>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 0 0 ${compareSplitPercent}%)`,
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-contain"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - compareSplitPercent}% 0 0)`,
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
<ImageNodeAdjustOverlays
adjustments={activeImageAdjustments}
hasActiveAdjustments={hasActiveImageAdjustments}
style={{ inset: 0 }}
/>
</div>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 0 0 ${compareSplitPercent}%)`,
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
}}
src={displayCompareSrc}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-30 w-px bg-white/95 shadow-[0_0_0_1px_rgba(15,23,42,0.18),0_0_6px_rgba(255,255,255,0.28)]"
style={{
left: `${compareSplitPercent}%`,
transform: "translateX(-50%)",
}}
src={displayCompareSrc}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-30 w-px bg-white/95 shadow-[0_0_0_1px_rgba(15,23,42,0.18),0_0_6px_rgba(255,255,255,0.28)]"
style={{
left: `${compareSplitPercent}%`,
transform: "translateX(-50%)",
}}
/>
<div
aria-hidden="true"
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 z-40 flex h-7 w-7 items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)]"
style={{ left: `${compareSplitPercent}%` }}
>
&lt;&gt;
<div
aria-hidden="true"
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 z-40 flex h-7 w-7 items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)]"
style={{
left: `clamp(14px, ${compareSplitPercent}%, calc(100% - 14px))`,
}}
>
&lt;&gt;
</div>
</div>
<button
className="-translate-x-1/2 absolute top-2 left-1/2 z-50 rounded-full border border-[#dbe2ee] bg-white px-2.5 py-1 text-[10px] font-medium text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] transition hover:bg-[#f7f9ff] hover:text-[#16181d]"

View File

@ -831,7 +831,7 @@ function BeforeAfterSlider({
const rect = sliderRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const next = ((clientX - rect.left) / rect.width) * 100;
setPosition(Math.min(96, Math.max(4, Math.round(next * 10) / 10)));
setPosition(Math.min(100, Math.max(0, Math.round(next * 10) / 10)));
}, []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -860,16 +860,16 @@ function BeforeAfterSlider({
const step = event.shiftKey ? 10 : 3;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
setPosition((current) => Math.max(4, current - step));
setPosition((current) => Math.max(0, current - step));
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
setPosition((current) => Math.min(96, current + step));
setPosition((current) => Math.min(100, current + step));
} else if (event.key === "Home") {
event.preventDefault();
setPosition(4);
setPosition(0);
} else if (event.key === "End") {
event.preventDefault();
setPosition(96);
setPosition(100);
}
},
[]
@ -918,25 +918,27 @@ function BeforeAfterSlider({
/>
</div>
<div
className="absolute inset-y-0 w-px bg-white"
className="pointer-events-none absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
/>
<div
aria-label="拖动查看原图和结果对比"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(position)}
className="-translate-x-1/2 absolute top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-white/80 bg-black/75 text-[11px] font-semibold text-white shadow-lg outline-none transition hover:bg-black focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:ring-offset-2"
onKeyDown={handleSliderKeyDown}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
role="slider"
style={{
left: `clamp(22px, ${position}%, calc(100% - 22px))`,
}}
tabIndex={0}
>
<div
aria-label="拖动查看原图和结果对比"
aria-valuemax={96}
aria-valuemin={4}
aria-valuenow={Math.round(position)}
className="-translate-x-1/2 absolute top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-white/80 bg-black/75 text-[11px] font-semibold text-white shadow-lg outline-none transition hover:bg-black focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:ring-offset-2"
onKeyDown={handleSliderKeyDown}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
role="slider"
tabIndex={0}
>
对比
</div>
对比
</div>
<div className="absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1 text-xs text-white/80">
原图