fix: align image comparison sliders
This commit is contained in:
parent
e7cd3d6e73
commit
b87b44c7b4
@ -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}%` }}
|
||||
>
|
||||
<>
|
||||
<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))`,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
</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]"
|
||||
|
||||
@ -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">
|
||||
原图
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user