feat: implement batch node removal and enhance canvas node management
- Replaced individual node removal with a batch removal mutation to improve performance and user experience. - Introduced optimistic UI updates to prevent flickering during node deletions. - Enhanced edge reconnection logic to automatically handle edges associated with deleted nodes. - Updated asset and image node components to support new metrics tracking for better diagnostics. - Refactored node resizing logic to ensure consistent behavior during drag-and-drop operations.
This commit is contained in:
@@ -18,7 +18,7 @@ import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { computeMediaNodeSize, resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
|
||||
type AssetNodeData = {
|
||||
assetId?: number;
|
||||
@@ -58,13 +58,14 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl,
|
||||
);
|
||||
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
||||
const aspectRatio = resolveMediaAspectRatio(
|
||||
data.intrinsicWidth,
|
||||
data.intrinsicHeight,
|
||||
data.orientation,
|
||||
);
|
||||
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const lastMetricsRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAsset) return;
|
||||
@@ -101,6 +102,56 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const showPreview = Boolean(hasAsset && previewUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const rootEl = rootRef.current;
|
||||
const headerEl = headerRef.current;
|
||||
if (!rootEl || !headerEl) return;
|
||||
|
||||
const rootHeight = rootEl.getBoundingClientRect().height;
|
||||
const headerHeight = headerEl.getBoundingClientRect().height;
|
||||
const previewHeight = previewRef.current?.getBoundingClientRect().height ?? null;
|
||||
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
|
||||
const imageEl = imageRef.current;
|
||||
const rootStyles = window.getComputedStyle(rootEl);
|
||||
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
|
||||
const rows = rootStyles.gridTemplateRows;
|
||||
const imageRect = imageEl?.getBoundingClientRect();
|
||||
const previewRect = previewRef.current?.getBoundingClientRect();
|
||||
const naturalRatio =
|
||||
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
|
||||
? imageEl.naturalWidth / imageEl.naturalHeight
|
||||
: null;
|
||||
const previewRatio =
|
||||
previewRect && previewRect.width > 0 && previewRect.height > 0
|
||||
? previewRect.width / previewRect.height
|
||||
: null;
|
||||
let expectedContainWidth: number | null = null;
|
||||
let expectedContainHeight: number | null = null;
|
||||
if (previewRect && naturalRatio) {
|
||||
const fitByWidthHeight = previewRect.width / naturalRatio;
|
||||
if (fitByWidthHeight <= previewRect.height) {
|
||||
expectedContainWidth = previewRect.width;
|
||||
expectedContainHeight = fitByWidthHeight;
|
||||
} else {
|
||||
expectedContainHeight = previewRect.height;
|
||||
expectedContainWidth = previewRect.height * naturalRatio;
|
||||
}
|
||||
}
|
||||
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight ?? -1)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showPreview}`;
|
||||
|
||||
if (lastMetricsRef.current === signature) {
|
||||
return;
|
||||
}
|
||||
lastMetricsRef.current = signature;
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H13-H14',location:'asset-node.tsx:metricsEffect',message:'asset contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect?.width ?? null,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showPreview},timestamp:Date.now()})}).catch(()=>{});
|
||||
// #endregion
|
||||
}, [height, id, selected, showPreview, width]);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="asset"
|
||||
@@ -115,8 +166,15 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`grid h-full min-h-0 w-full ${
|
||||
showPreview
|
||||
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
||||
: "grid-rows-[auto_minmax(0,1fr)]"
|
||||
}`}
|
||||
>
|
||||
<div ref={headerRef} className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Asset
|
||||
</span>
|
||||
@@ -131,12 +189,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasAsset && previewUrl ? (
|
||||
<div className="flex flex-col gap-0">
|
||||
<div
|
||||
className="relative overflow-hidden bg-muted/30"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{showPreview ? (
|
||||
<>
|
||||
<div ref={previewRef} className="relative min-h-0 overflow-hidden bg-muted/30">
|
||||
{isPreviewLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
|
||||
Loading preview...
|
||||
@@ -149,9 +204,10 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={previewUrl}
|
||||
alt={data.title ?? "Asset preview"}
|
||||
className={`h-full w-full object-contain transition-opacity ${
|
||||
className={`h-full w-full object-cover object-center transition-opacity ${
|
||||
isPreviewLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
draggable={false}
|
||||
@@ -178,7 +234,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 px-3 py-2">
|
||||
<div ref={footerRef} className="flex flex-col gap-1 px-3 py-2">
|
||||
<p className="truncate text-xs font-medium" title={data.title ?? "Untitled"}>
|
||||
{data.title ?? "Untitled"}
|
||||
</p>
|
||||
@@ -200,9 +256,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-4 py-8 text-center">
|
||||
<div className="flex min-h-0 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, type ReactNode } from "react";
|
||||
import { NodeResizeControl, type ShouldResize } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NodeResizeControl } from "@xyflow/react";
|
||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||
|
||||
interface ResizeConfig {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
keepAspectRatio?: boolean;
|
||||
contentAware?: boolean;
|
||||
}
|
||||
|
||||
const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
||||
frame: { minWidth: 200, minHeight: 150 },
|
||||
group: { minWidth: 150, minHeight: 100 },
|
||||
image: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
|
||||
asset: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
|
||||
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
|
||||
asset: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
|
||||
"ai-image": { minWidth: 200, minHeight: 200 },
|
||||
compare: { minWidth: 300, minHeight: 200 },
|
||||
prompt: { minWidth: 240, minHeight: 200, contentAware: true },
|
||||
text: { minWidth: 180, minHeight: 80, contentAware: true },
|
||||
note: { minWidth: 160, minHeight: 80, contentAware: true },
|
||||
prompt: { minWidth: 260, minHeight: 200 },
|
||||
text: { minWidth: 220, minHeight: 90 },
|
||||
note: { minWidth: 200, minHeight: 90 },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50, contentAware: true };
|
||||
const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50 };
|
||||
|
||||
const CORNERS = [
|
||||
"top-left",
|
||||
@@ -49,7 +48,6 @@ export default function BaseNodeWrapper({
|
||||
children,
|
||||
className = "",
|
||||
}: BaseNodeWrapperProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG;
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
@@ -61,37 +59,10 @@ export default function BaseNodeWrapper({
|
||||
error: "border-red-500",
|
||||
};
|
||||
|
||||
const shouldResize: ShouldResize = useCallback(
|
||||
(event, params) => {
|
||||
if (!wrapperRef.current || !config.contentAware) return true;
|
||||
|
||||
const contentEl = wrapperRef.current;
|
||||
const paddingX =
|
||||
parseFloat(getComputedStyle(contentEl).paddingLeft) +
|
||||
parseFloat(getComputedStyle(contentEl).paddingRight);
|
||||
const paddingY =
|
||||
parseFloat(getComputedStyle(contentEl).paddingTop) +
|
||||
parseFloat(getComputedStyle(contentEl).paddingBottom);
|
||||
|
||||
const minW = Math.max(
|
||||
config.minWidth,
|
||||
contentEl.scrollWidth - paddingX + paddingX * 0.5,
|
||||
);
|
||||
const minH = Math.max(
|
||||
config.minHeight,
|
||||
contentEl.scrollHeight - paddingY + paddingY * 0.5,
|
||||
);
|
||||
|
||||
return params.width >= minW && params.height >= minH;
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`
|
||||
rounded-xl border bg-card shadow-sm transition-shadow
|
||||
h-full w-full rounded-xl border bg-card shadow-sm transition-shadow
|
||||
${selected ? "ring-2 ring-primary shadow-md" : ""}
|
||||
${statusStyles[status] ?? ""}
|
||||
${className}
|
||||
@@ -105,7 +76,6 @@ export default function BaseNodeWrapper({
|
||||
minWidth={config.minWidth}
|
||||
minHeight={config.minHeight}
|
||||
keepAspectRatio={config.keepAspectRatio}
|
||||
shouldResize={shouldResize}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
|
||||
@@ -12,11 +12,10 @@ import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import NextImage from "next/image";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { computeMediaNodeSize, resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
"image/png",
|
||||
@@ -79,8 +78,12 @@ export default function ImageNode({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
|
||||
const aspectRatio = resolveMediaAspectRatio(data.width, data.height);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const footerRef = useRef<HTMLParagraphElement>(null);
|
||||
const lastMetricsRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
||||
@@ -230,6 +233,57 @@ export default function ImageNode({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const showFilename = Boolean(data.filename && data.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const rootEl = rootRef.current;
|
||||
const headerEl = headerRef.current;
|
||||
const previewEl = previewRef.current;
|
||||
if (!rootEl || !headerEl || !previewEl) return;
|
||||
|
||||
const rootHeight = rootEl.getBoundingClientRect().height;
|
||||
const headerHeight = headerEl.getBoundingClientRect().height;
|
||||
const previewHeight = previewEl.getBoundingClientRect().height;
|
||||
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
|
||||
const imageEl = imageRef.current;
|
||||
const rootStyles = window.getComputedStyle(rootEl);
|
||||
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
|
||||
const rows = rootStyles.gridTemplateRows;
|
||||
const imageRect = imageEl?.getBoundingClientRect();
|
||||
const previewRect = previewEl.getBoundingClientRect();
|
||||
const naturalRatio =
|
||||
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
|
||||
? imageEl.naturalWidth / imageEl.naturalHeight
|
||||
: null;
|
||||
const previewRatio =
|
||||
previewRect.width > 0 && previewRect.height > 0
|
||||
? previewRect.width / previewRect.height
|
||||
: null;
|
||||
let expectedContainWidth: number | null = null;
|
||||
let expectedContainHeight: number | null = null;
|
||||
if (naturalRatio) {
|
||||
const fitByWidthHeight = previewRect.width / naturalRatio;
|
||||
if (fitByWidthHeight <= previewRect.height) {
|
||||
expectedContainWidth = previewRect.width;
|
||||
expectedContainHeight = fitByWidthHeight;
|
||||
} else {
|
||||
expectedContainHeight = previewRect.height;
|
||||
expectedContainWidth = previewRect.height * naturalRatio;
|
||||
}
|
||||
}
|
||||
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showFilename}`;
|
||||
|
||||
if (lastMetricsRef.current === signature) {
|
||||
return;
|
||||
}
|
||||
lastMetricsRef.current = signature;
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H15-H16',location:'image-node.tsx:metricsEffect',message:'image contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect.width,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showFilename},timestamp:Date.now()})}).catch(()=>{});
|
||||
// #endregion
|
||||
}, [height, id, selected, showFilename, width]);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="image"
|
||||
@@ -243,8 +297,15 @@ export default function ImageNode({
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
/>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`grid h-full min-h-0 w-full grid-cols-1 gap-y-1 p-2 ${
|
||||
showFilename
|
||||
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
||||
: "grid-rows-[auto_minmax(0,1fr)]"
|
||||
}`}
|
||||
>
|
||||
<div ref={headerRef} className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
{data.url && (
|
||||
<button
|
||||
@@ -256,7 +317,7 @@ export default function ImageNode({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}>
|
||||
<div ref={previewRef} className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
|
||||
{isUploading ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -265,12 +326,12 @@ export default function ImageNode({
|
||||
</div>
|
||||
</div>
|
||||
) : data.url ? (
|
||||
<NextImage
|
||||
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={data.url}
|
||||
alt={data.filename ?? "Bild"}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, 260px"
|
||||
className="h-full w-full object-cover object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
@@ -280,8 +341,8 @@ export default function ImageNode({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
nodrag flex w-full cursor-pointer flex-col items-center justify-center
|
||||
h-full border-2 border-dashed text-sm transition-colors
|
||||
nodrag flex h-full w-full cursor-pointer flex-col items-center justify-center
|
||||
border-2 border-dashed text-sm transition-colors
|
||||
${
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
@@ -296,11 +357,9 @@ export default function ImageNode({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.filename && data.url && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{data.filename}
|
||||
</p>
|
||||
)}
|
||||
{showFilename ? (
|
||||
<p ref={footerRef} className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<input
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
) : (
|
||||
<div
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap"
|
||||
className="min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm"
|
||||
>
|
||||
{content || (
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
) : (
|
||||
<div
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap overflow-wrap-break-word"
|
||||
className="min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm"
|
||||
>
|
||||
{content || (
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user