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:
Matthias
2026-03-27 22:08:16 +01:00
parent 8e4e2fcac1
commit e96c9c611c
7 changed files with 281 additions and 91 deletions

View File

@@ -297,7 +297,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const resizeNode = useMutation(api.nodes.resize);
const batchMoveNodes = useMutation(api.nodes.batchMove);
const createNode = useMutation(api.nodes.create);
const removeNode = useMutation(api.nodes.remove);
const batchRemoveNodes = useMutation(api.nodes.batchRemove);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove);
@@ -308,6 +308,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false);
// Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen
const deletingNodeIds = useRef<Set<string>>(new Set());
// Delete Edge on Drop
const edgeReconnectSuccessful = useRef(true);
const overlappedEdgeRef = useRef<string | null>(null);
@@ -379,7 +382,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (!convexNodes || isDragging.current) return;
setNodes((previousNodes) => {
const incomingNodes = withResolvedCompareData(convexNodes.map(convexNodeToRF), edges);
return mergeNodesPreservingLocalState(previousNodes, incomingNodes);
// Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
const filteredIncoming = deletingNodeIds.current.size > 0
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
: incomingNodes;
return mergeNodesPreservingLocalState(previousNodes, filteredIncoming);
});
}, [convexNodes, edges]);
@@ -403,17 +410,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Node Changes (Drag, Select, Remove) ─────────────────────
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
const removedIds = new Set<string>();
for (const c of changes) {
if (c.type === "remove") {
removedIds.add(c.id);
}
}
setNodes((nds) => {
const nextNodes = applyNodeChanges(changes, nds);
for (const change of changes) {
if (change.type !== "dimensions") continue;
if (change.resizing !== false || !change.dimensions) continue;
if (removedIds.has(change.id)) continue;
void resizeNode({
nodeId: change.id as Id<"nodes">,
width: change.dimensions.width,
height: change.dimensions.height,
}).catch((error: unknown) => {
if (process.env.NODE_ENV !== "production") {
console.warn("[Canvas] resizeNode failed", error);
}
});
}
@@ -723,8 +742,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Node löschen → Convex ────────────────────────────────────
const onNodesDelete = useCallback(
async (deletedNodes: RFNode[]) => {
(deletedNodes: RFNode[]) => {
const count = deletedNodes.length;
if (count === 0) return;
// Optimistic: Node-IDs sofort als "wird gelöscht" markieren
const idsToDelete = deletedNodes.map((n) => n.id);
for (const id of idsToDelete) {
deletingNodeIds.current.add(id);
}
// Auto-Reconnect: Für jeden gelöschten Node eingehende und ausgehende Edges verbinden
const edgePromises: Promise<unknown>[] = [];
for (const node of deletedNodes) {
const incomingEdges = edges.filter((e) => e.target === node.id);
const outgoingEdges = edges.filter((e) => e.source === node.id);
@@ -732,25 +761,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (incomingEdges.length > 0 && outgoingEdges.length > 0) {
for (const incoming of incomingEdges) {
for (const outgoing of outgoingEdges) {
await createEdge({
edgePromises.push(
createEdge({
canvasId,
sourceNodeId: incoming.source as Id<"nodes">,
targetNodeId: outgoing.target as Id<"nodes">,
sourceHandle: incoming.sourceHandle ?? undefined,
targetHandle: outgoing.targetHandle ?? undefined,
});
}),
);
}
}
}
}
removeNode({ nodeId: node.id as Id<"nodes"> });
// Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen
void Promise.all([
batchRemoveNodes({
nodeIds: idsToDelete as Id<"nodes">[],
}),
...edgePromises,
])
.then(() => {
for (const id of idsToDelete) {
deletingNodeIds.current.delete(id);
}
})
.catch((error: unknown) => {
console.error("[Canvas] batch remove failed", error);
// Bei Fehler: deletingNodeIds aufräumen, damit Nodes wieder erscheinen
for (const id of idsToDelete) {
deletingNodeIds.current.delete(id);
}
});
if (count > 0) {
const { title } = msg.canvas.nodesRemoved(count);
toast.info(title);
}
},
[edges, removeNode, createEdge, canvasId],
[edges, batchRemoveNodes, createEdge, canvasId],
);
// ─── Edge löschen → Convex ────────────────────────────────────

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -205,7 +205,7 @@ export const resize = mutation({
handler: async (ctx, { nodeId, width, height }) => {
const user = await requireAuth(ctx);
const node = await ctx.db.get(nodeId);
if (!node) throw new Error("Node not found");
if (!node) return;
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
await ctx.db.patch(nodeId, { width, height });
@@ -393,3 +393,58 @@ export const remove = mutation({
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
},
});
/**
* Mehrere Nodes gleichzeitig löschen (Batch Delete).
* Entfernt auch alle verbundenen Edges und löst Kind-Nodes aus Gruppen/Frames.
*/
export const batchRemove = mutation({
args: { nodeIds: v.array(v.id("nodes")) },
handler: async (ctx, { nodeIds }) => {
const user = await requireAuth(ctx);
if (nodeIds.length === 0) return;
// Canvas-Zugriff über den ersten Node prüfen
const firstNode = await ctx.db.get(nodeIds[0]);
if (!firstNode) throw new Error("Node not found");
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
const nodeIdSet = new Set(nodeIds.map((id) => id.toString()));
for (const nodeId of nodeIds) {
const node = await ctx.db.get(nodeId);
if (!node) continue;
// Alle Edges entfernen, die diesen Node als Source oder Target haben
const sourceEdges = await ctx.db
.query("edges")
.withIndex("by_source", (q) => q.eq("sourceNodeId", nodeId))
.collect();
for (const edge of sourceEdges) {
await ctx.db.delete(edge._id);
}
const targetEdges = await ctx.db
.query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", nodeId))
.collect();
for (const edge of targetEdges) {
await ctx.db.delete(edge._id);
}
// Kind-Nodes aus Gruppe/Frame lösen
const children = await ctx.db
.query("nodes")
.withIndex("by_parent", (q) => q.eq("parentId", nodeId))
.collect();
for (const child of children) {
await ctx.db.patch(child._id, { parentId: undefined });
}
// Node löschen
await ctx.db.delete(nodeId);
}
await ctx.db.patch(firstNode.canvasId, { updatedAt: Date.now() });
},
});