Enhance canvas functionality with storage URL resolution and debugging improvements

- Added a fallback mechanism for resolving storage URLs in `canvas-helpers.ts`, improving reliability when URLs are not directly available.
- Introduced new utility functions in `canvas.tsx` for summarizing update and resize payloads, enhancing debugging capabilities during canvas operations.
- Updated `compare-node.tsx` to improve state management and rendering logic, allowing for better handling of incoming edges and display modes.
- Refactored `render-node.tsx` to streamline the rendering process and include detailed logging for debugging render operations.
- Updated `.gitignore` to exclude `.kilo` files, ensuring cleaner repository management.
This commit is contained in:
2026-04-02 16:12:56 +02:00
parent 3fa686d60d
commit 8703387617
7 changed files with 669 additions and 123 deletions

View File

@@ -1,9 +1,15 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react";
import { ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
import CompareSurface from "./compare-surface";
import {
resolveRenderPipelineHash,
resolveRenderPreviewInput,
type RenderPreviewInput,
} from "@/lib/canvas-render-preview";
interface CompareNodeData {
leftUrl?: string;
@@ -12,13 +18,140 @@ interface CompareNodeData {
rightLabel?: string;
}
export default function CompareNode({ data, selected }: NodeProps) {
type CompareSide = "left" | "right";
type CompareSideState = {
finalUrl?: string;
label?: string;
previewInput?: RenderPreviewInput;
isStaleRenderOutput: boolean;
};
type CompareDisplayMode = "render" | "preview";
export default function CompareNode({ id, data, selected, width }: NodeProps) {
const nodeData = data as CompareNodeData;
const nodes = useStore((state) => state.nodes);
const edges = useStore((state) => state.edges);
const [sliderX, setSliderX] = useState(50);
const [manualDisplayMode, setManualDisplayMode] = useState<CompareDisplayMode | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hasLeft = !!nodeData.leftUrl;
const hasRight = !!nodeData.rightUrl;
const pipelineNodes = useMemo(
() => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })),
[nodes],
);
const pipelineEdges = useMemo(
() => edges.map((edge) => ({ source: edge.source, target: edge.target })),
[edges],
);
const nodesById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const incomingEdges = useMemo(
() =>
edges.filter(
(edge) =>
edge.target === id &&
edge.className !== "temp" &&
(edge.targetHandle === "left" || edge.targetHandle === "right"),
),
[edges, id],
);
const resolvedSides = useMemo(() => {
const resolveSide = (
side: CompareSide,
finalUrl: string | undefined,
finalLabel: string | undefined,
defaultLabel: string,
): CompareSideState => {
const incomingEdge = incomingEdges.find((edge) => edge.targetHandle === side);
const sourceNode = incomingEdge ? nodesById.get(incomingEdge.source) : undefined;
const sourceData = (sourceNode?.data ?? {}) as Record<string, unknown>;
const sourceLabel =
typeof sourceData.label === "string" && sourceData.label.length > 0
? sourceData.label
: sourceNode?.type;
const label = finalLabel ?? sourceLabel ?? defaultLabel;
let previewInput: RenderPreviewInput | undefined;
let isStaleRenderOutput = false;
if (sourceNode && sourceNode.type === "render") {
const preview = resolveRenderPreviewInput({
nodeId: sourceNode.id,
nodes: pipelineNodes,
edges: pipelineEdges,
});
if (preview.sourceUrl) {
previewInput = {
sourceUrl: preview.sourceUrl,
steps: preview.steps,
};
const sourceLastUploadedHash =
typeof sourceData.lastUploadedHash === "string"
? sourceData.lastUploadedHash
: undefined;
const sourceLastRenderedHash =
typeof sourceData.lastRenderedHash === "string"
? sourceData.lastRenderedHash
: undefined;
const sourcePersistedOutputHash =
sourceLastUploadedHash ?? sourceLastRenderedHash;
const sourceCurrentHash = resolveRenderPipelineHash({
sourceUrl: preview.sourceUrl,
steps: preview.steps,
data: sourceData,
});
isStaleRenderOutput =
Boolean(finalUrl) &&
Boolean(sourceCurrentHash) &&
Boolean(sourcePersistedOutputHash) &&
sourceCurrentHash !== sourcePersistedOutputHash;
}
}
if (finalUrl) {
return { finalUrl, label, previewInput, isStaleRenderOutput };
}
return { label, previewInput, isStaleRenderOutput };
};
return {
left: resolveSide("left", nodeData.leftUrl, nodeData.leftLabel, "Before"),
right: resolveSide("right", nodeData.rightUrl, nodeData.rightLabel, "After"),
};
}, [
incomingEdges,
nodeData.leftLabel,
nodeData.leftUrl,
nodeData.rightLabel,
nodeData.rightUrl,
nodesById,
pipelineEdges,
pipelineNodes,
]);
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput);
const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput);
const hasConnectedRenderInput = useMemo(
() =>
incomingEdges.some((edge) => {
const sourceNode = nodesById.get(edge.source);
return sourceNode?.type === "render";
}),
[incomingEdges, nodesById],
);
const shouldDefaultToPreview =
resolvedSides.left.isStaleRenderOutput || resolvedSides.right.isStaleRenderOutput;
const effectiveDisplayMode =
manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render");
const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500)));
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
@@ -86,7 +219,33 @@ export default function CompareNode({ data, selected }: NodeProps) {
/>
<div className="grid h-full min-h-0 w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Compare</div>
<div className="flex items-center justify-between gap-2 px-3 py-2">
<div className="text-xs font-medium text-muted-foreground"> Compare</div>
{hasConnectedRenderInput && (
<div className="nodrag inline-flex rounded-md border border-border bg-background/80 p-0.5">
<button
type="button"
className={`rounded px-2 py-0.5 text-[10px] font-medium ${effectiveDisplayMode === "render" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
onClick={(event) => {
event.stopPropagation();
setManualDisplayMode("render");
}}
>
Render
</button>
<button
type="button"
className={`rounded px-2 py-0.5 text-[10px] font-medium ${effectiveDisplayMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
onClick={(event) => {
event.stopPropagation();
setManualDisplayMode("preview");
}}
>
Preview
</button>
</div>
)}
</div>
<div
ref={containerRef}
@@ -94,81 +253,76 @@ export default function CompareNode({ data, selected }: NodeProps) {
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{!hasLeft && !hasRight && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" />
<p className="px-8 text-center text-xs opacity-60">
Connect two image nodes - left handle (blue) and right handle (green)
</p>
</div>
)}
{hasRight && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={nodeData.rightUrl}
alt={nodeData.rightLabel ?? "Right"}
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
draggable={false}
/>
)}
{hasLeft && (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{ width: `${sliderX}%` }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={nodeData.leftUrl}
alt={nodeData.leftLabel ?? "Left"}
className="absolute inset-0 h-full w-full object-contain"
style={{ width: "100%", maxWidth: "none" }}
draggable={false}
/>
</div>
)}
{hasLeft && hasRight && (
<>
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md"
style={{ left: `${sliderX}%` }}
/>
<div
className="pointer-events-none absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2"
style={{ left: `${sliderX}%` }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5 8H1M11 8H15M5 5L2 8L5 11M11 5L14 8L11 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{!hasLeft && !hasRight && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" />
<p className="px-8 text-center text-xs opacity-60">
Connect two image nodes - left handle (blue) and right handle (green)
</p>
</div>
</>
)}
)}
{hasLeft && (
<div className="pointer-events-none absolute left-2 top-2 z-10">
<span className="rounded bg-blue-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{nodeData.leftLabel ?? "Before"}
</span>
</div>
)}
{hasRight && (
<CompareSurface
finalUrl={resolvedSides.right.finalUrl}
label={resolvedSides.right.label}
previewInput={resolvedSides.right.previewInput}
nodeWidth={previewNodeWidth}
preferPreview={effectiveDisplayMode === "preview"}
/>
)}
{hasRight && (
<div className="pointer-events-none absolute right-2 top-2 z-10">
<span className="rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{nodeData.rightLabel ?? "After"}
</span>
</div>
)}
{hasLeft && (
<CompareSurface
finalUrl={resolvedSides.left.finalUrl}
label={resolvedSides.left.label}
previewInput={resolvedSides.left.previewInput}
nodeWidth={previewNodeWidth}
clipWidthPercent={sliderX}
preferPreview={effectiveDisplayMode === "preview"}
/>
)}
{hasLeft && hasRight && (
<>
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md"
style={{ left: `${sliderX}%` }}
/>
<div
className="pointer-events-none absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2"
style={{ left: `${sliderX}%` }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5 8H1M11 8H15M5 5L2 8L5 11M11 5L14 8L11 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
</>
)}
{hasLeft && (
<div className="pointer-events-none absolute left-2 top-2 z-10">
<span className="rounded bg-blue-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{resolvedSides.left.label ?? "Before"}
</span>
</div>
)}
{hasRight && (
<div className="pointer-events-none absolute right-2 top-2 z-10">
<span className="rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{resolvedSides.right.label ?? "After"}
</span>
</div>
)}
</div>
</div>
</BaseNodeWrapper>