feat: refactor canvas and node components for improved functionality and styling

- Removed unused hooks and optimized edge handling in the canvas component.
- Adjusted positioning of handles in the compare node for better alignment.
- Enhanced prompt node to utilize incoming edges for dynamic prompt generation and improved user feedback.
- Updated text node to synchronize content changes with the React Flow state.
- Improved logging in edge removal to handle idempotent operations gracefully.
This commit is contained in:
Matthias
2026-03-26 17:35:25 +01:00
parent 824939307c
commit a5cde14573
6 changed files with 158 additions and 260 deletions

View File

@@ -11,8 +11,6 @@ import {
applyNodeChanges, applyNodeChanges,
applyEdgeChanges, applyEdgeChanges,
useReactFlow, useReactFlow,
useStoreApi,
useNodesInitialized,
reconnectEdge, reconnectEdge,
type Node as RFNode, type Node as RFNode,
type Edge as RFEdge, type Edge as RFEdge,
@@ -29,7 +27,7 @@ import type { Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { nodeTypes } from "./node-types"; import { nodeTypes } from "./node-types";
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import CanvasToolbar from "@/components/canvas/canvas-toolbar";
interface CanvasInnerProps { interface CanvasInnerProps {
@@ -99,12 +97,8 @@ function getMiniMapNodeStrokeColor(node: RFNode): string {
return node.type === "frame" ? "transparent" : "#4f46e5"; return node.type === "frame" ? "transparent" : "#4f46e5";
} }
const MIN_DISTANCE = 150;
function CanvasInner({ canvasId }: CanvasInnerProps) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const { screenToFlowPosition, getInternalNode } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const store = useStoreApi();
const nodesInitialized = useNodesInitialized();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession(); const { data: session, isPending: isSessionPending } = authClient.useSession();
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
@@ -181,7 +175,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// Delete Edge on Drop // Delete Edge on Drop
const edgeReconnectSuccessful = useRef(true); const edgeReconnectSuccessful = useRef(true);
const uninitializedDragNodeIds = useRef<Set<string>>(new Set());
// ─── Convex → Lokaler State Sync ────────────────────────────── // ─── Convex → Lokaler State Sync ──────────────────────────────
useEffect(() => { useEffect(() => {
@@ -256,175 +249,26 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
(_: MouseEvent | TouchEvent, edge: RFEdge) => { (_: MouseEvent | TouchEvent, edge: RFEdge) => {
if (!edgeReconnectSuccessful.current) { if (!edgeReconnectSuccessful.current) {
setEdges((eds) => eds.filter((e) => e.id !== edge.id)); setEdges((eds) => eds.filter((e) => e.id !== edge.id));
removeEdge({ edgeId: edge.id as Id<"edges"> }); if (edge.className === "temp") {
edgeReconnectSuccessful.current = true;
return;
}
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
console.error("[Canvas edge remove failed] reconnect end", {
edgeId: edge.id,
edgeClassName: edge.className ?? null,
source: edge.source,
target: edge.target,
error: String(error),
});
});
} }
edgeReconnectSuccessful.current = true; edgeReconnectSuccessful.current = true;
}, },
[removeEdge], [removeEdge],
); );
// ─── Proximity Connect ────────────────────────────────────────
const getClosestEdge = useCallback(
(node: RFNode) => {
if (!nodesInitialized) {
if (!uninitializedDragNodeIds.current.has(node.id)) {
uninitializedDragNodeIds.current.add(node.id);
console.warn("[Canvas debug] proximity skipped: nodes not initialized", {
canvasId,
nodeId: node.id,
nodeType: node.type,
});
}
return null;
}
const { nodeLookup } = store.getState();
const internalNode = getInternalNode(node.id);
if (!internalNode) {
if (!uninitializedDragNodeIds.current.has(node.id)) {
uninitializedDragNodeIds.current.add(node.id);
console.warn("[Canvas debug] proximity skipped: missing internal node", {
canvasId,
nodeId: node.id,
nodeType: node.type,
nodeLookupSize: nodeLookup.size,
});
}
return null;
}
const getNodeSize = (n: {
measured?: { width?: number; height?: number };
width?: number;
height?: number;
internals?: { userNode?: { width?: number; height?: number } };
}) => {
const width =
n.measured?.width ?? n.width ?? n.internals?.userNode?.width ?? 0;
const height =
n.measured?.height ?? n.height ?? n.internals?.userNode?.height ?? 0;
return { width, height };
};
const rectDistance = (
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
) => {
const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
return Math.sqrt(dx * dx + dy * dy);
};
const thisSize = getNodeSize(internalNode);
const thisRect = {
x: internalNode.internals.positionAbsolute.x,
y: internalNode.internals.positionAbsolute.y,
width: thisSize.width,
height: thisSize.height,
};
let minDist = Number.MAX_VALUE;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let closestN: any = null;
for (const n of nodeLookup.values()) {
if (n.id !== internalNode.id) {
const nSize = getNodeSize(n);
const nRect = {
x: n.internals.positionAbsolute.x,
y: n.internals.positionAbsolute.y,
width: nSize.width,
height: nSize.height,
};
const d = rectDistance(thisRect, nRect);
if (d < minDist) {
minDist = d;
closestN = n;
}
}
}
if (!closestN || minDist >= MIN_DISTANCE) {
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas proximity debug] skipped: distance", {
canvasId,
nodeId: node.id,
nodeType: node.type,
closestNodeId: closestN?.id ?? null,
closestNodeType: closestN?.type ?? null,
minDist,
minDistanceThreshold: MIN_DISTANCE,
});
}
return null;
}
const closeNodeIsSource =
closestN.internals.positionAbsolute.x <
internalNode.internals.positionAbsolute.x;
const sourceNode = closeNodeIsSource ? closestN : internalNode;
const targetNode = closeNodeIsSource ? internalNode : closestN;
const srcHandles = NODE_HANDLE_MAP[sourceNode.type ?? ""] ?? {};
const tgtHandles = NODE_HANDLE_MAP[targetNode.type ?? ""] ?? {};
if (!("source" in srcHandles) || !("target" in tgtHandles)) {
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas proximity debug] skipped: handle map", {
canvasId,
nodeId: node.id,
nodeType: node.type,
sourceNodeId: sourceNode.id,
sourceType: sourceNode.type,
targetNodeId: targetNode.id,
targetType: targetNode.type,
sourceHandles: srcHandles,
targetHandles: tgtHandles,
minDist,
});
}
return null;
}
// #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':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run3',hypothesisId:'H2-fix',location:'canvas.tsx:getClosestEdge',message:'proximity match with handles',data:{sourceId:sourceNode.id,sourceType:sourceNode.type,targetId:targetNode.id,targetType:targetNode.type,sourceHandle:srcHandles.source,targetHandle:tgtHandles.target,minDist},timestamp:Date.now()})}).catch(()=>{});
// #endregion
return {
id: closeNodeIsSource
? `${closestN.id}-${node.id}`
: `${node.id}-${closestN.id}`,
source: sourceNode.id,
target: targetNode.id,
sourceHandle: srcHandles.source,
targetHandle: tgtHandles.target,
};
},
[store, getInternalNode, nodesInitialized, canvasId],
);
const onNodeDrag = useCallback(
(_: React.MouseEvent, node: RFNode) => {
const closeEdge = getClosestEdge(node);
setEdges((es) => {
const nextEdges = es.filter((e) => e.className !== "temp");
if (
closeEdge &&
!nextEdges.find(
(ne) =>
ne.source === closeEdge.source && ne.target === closeEdge.target,
)
) {
nextEdges.push({ ...closeEdge, className: "temp" });
}
return nextEdges;
});
},
[getClosestEdge],
);
// ─── Drag Start → Lock ──────────────────────────────────────── // ─── Drag Start → Lock ────────────────────────────────────────
const onNodeDragStart = useCallback(() => { const onNodeDragStart = useCallback(() => {
isDragging.current = true; isDragging.current = true;
@@ -433,62 +277,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Drag Stop → Commit zu Convex ───────────────────────────── // ─── Drag Stop → Commit zu Convex ─────────────────────────────
const onNodeDragStop = useCallback( const onNodeDragStop = useCallback(
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
// Proximity Connect: closeEdge bestimmen bevor isDragging zurückgesetzt wird
const closeEdge = getClosestEdge(node);
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas proximity debug] drag stop decision", {
canvasId,
nodeId: node.id,
nodeType: node.type,
draggedCount: draggedNodes.length,
closeEdge,
});
}
// Proximity Connect: temporäre Edge entfernen, ggf. echte Edge anlegen
setEdges((es) => {
const nextEdges = es.filter((e) => e.className !== "temp");
if (
closeEdge &&
!nextEdges.find(
(ne) =>
ne.source === closeEdge.source && ne.target === closeEdge.target,
)
) {
void createEdge({
canvasId,
sourceNodeId: closeEdge.source as Id<"nodes">,
targetNodeId: closeEdge.target as Id<"nodes">,
sourceHandle: closeEdge.sourceHandle ?? undefined,
targetHandle: closeEdge.targetHandle ?? undefined,
})
.then((edgeId) => {
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas proximity debug] edge created", {
canvasId,
edgeId,
sourceNodeId: closeEdge.source,
targetNodeId: closeEdge.target,
sourceHandle: closeEdge.sourceHandle ?? null,
targetHandle: closeEdge.targetHandle ?? null,
});
}
})
.catch((error) => {
console.error("[Canvas proximity debug] edge create failed", {
canvasId,
sourceNodeId: closeEdge.source,
targetNodeId: closeEdge.target,
sourceHandle: closeEdge.sourceHandle ?? null,
targetHandle: closeEdge.targetHandle ?? null,
error: String(error),
});
});
}
return nextEdges;
});
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich // isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
if (draggedNodes.length > 1) { if (draggedNodes.length > 1) {
void batchMoveNodes({ void batchMoveNodes({
@@ -510,7 +298,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
} }
}, },
[moveNode, batchMoveNodes, getClosestEdge, createEdge, canvasId], [moveNode, batchMoveNodes],
); );
// ─── Neue Verbindung → Convex Edge ──────────────────────────── // ─── Neue Verbindung → Convex Edge ────────────────────────────
@@ -560,7 +348,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const onEdgesDelete = useCallback( const onEdgesDelete = useCallback(
(deletedEdges: RFEdge[]) => { (deletedEdges: RFEdge[]) => {
for (const edge of deletedEdges) { for (const edge of deletedEdges) {
removeEdge({ edgeId: edge.id as Id<"edges"> }); if (edge.className === "temp") {
continue;
}
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
console.error("[Canvas edge remove failed] edge delete", {
edgeId: edge.id,
edgeClassName: edge.className ?? null,
source: edge.source,
target: edge.target,
error: String(error),
});
});
} }
}, },
[removeEdge], [removeEdge],
@@ -627,7 +427,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeDrag={onNodeDrag}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
onConnect={onConnect} onConnect={onConnect}

View File

@@ -70,14 +70,14 @@ export default function CompareNode({ data, selected }: NodeProps) {
type="target" type="target"
position={Position.Left} position={Position.Left}
id="left" id="left"
style={{ top: "40%" }} style={{ top: "35%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500" className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
/> />
<Handle <Handle
type="target" type="target"
position={Position.Right} position={Position.Left}
id="right" id="right"
style={{ top: "40%" }} style={{ top: "55%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
/> />

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react"; import {
Handle,
Position,
useReactFlow,
useStore,
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation, useAction } from "convex/react"; import { useMutation, useAction } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -52,6 +59,8 @@ export default function PromptNode({
); );
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const edges = useStore((store) => store.edges);
const nodes = useStore((store) => store.nodes);
const promptRef = useRef(prompt); const promptRef = useRef(prompt);
const aspectRatioRef = useRef(aspectRatio); const aspectRatioRef = useRef(aspectRatio);
@@ -66,6 +75,31 @@ export default function PromptNode({
setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO); setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO);
}, [nodeData.aspectRatio]); }, [nodeData.aspectRatio]);
const inputMeta = useMemo(() => {
const incomingEdges = edges.filter((edge) => edge.target === id);
let textPrompt: string | undefined;
let hasTextInput = false;
for (const edge of incomingEdges) {
const sourceNode = nodes.find((node) => node.id === edge.source);
if (sourceNode?.type !== "text") continue;
hasTextInput = true;
const sourceData = sourceNode.data as { content?: string };
if (typeof sourceData.content === "string") {
textPrompt = sourceData.content;
break;
}
}
return {
hasTextInput,
textPrompt: textPrompt ?? "",
};
}, [edges, id, nodes]);
const effectivePrompt = inputMeta.hasTextInput ? inputMeta.textPrompt : prompt;
const dataRef = useRef(data); const dataRef = useRef(data);
dataRef.current = data; dataRef.current = data;
@@ -107,29 +141,38 @@ export default function PromptNode({
); );
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGenerating) return; if (!effectivePrompt.trim() || isGenerating) return;
setError(null); setError(null);
setIsGenerating(true); setIsGenerating(true);
try { try {
const canvasId = nodeData.canvasId as Id<"canvases">; const canvasId = nodeData.canvasId as Id<"canvases">;
if (!canvasId) throw new Error("Missing canvasId on node"); if (!canvasId) throw new Error("Canvas-ID fehlt in der Node");
const edges = getEdges(); const currentEdges = getEdges();
const incomingEdges = edges.filter((e) => e.target === id); const incomingEdges = currentEdges.filter((e) => e.target === id);
let connectedTextPrompt: string | undefined;
let referenceStorageId: Id<"_storage"> | undefined; let referenceStorageId: Id<"_storage"> | undefined;
for (const edge of incomingEdges) { for (const edge of incomingEdges) {
const sourceNode = getNode(edge.source); const sourceNode = getNode(edge.source);
if (sourceNode?.type === "text") {
const srcData = sourceNode.data as { content?: string };
if (typeof srcData.content === "string") {
connectedTextPrompt = srcData.content;
}
}
if (sourceNode?.type === "image") { if (sourceNode?.type === "image") {
const srcData = sourceNode.data as { storageId?: string }; const srcData = sourceNode.data as { storageId?: string };
if (srcData.storageId) { if (srcData.storageId) {
referenceStorageId = srcData.storageId as Id<"_storage">; referenceStorageId = srcData.storageId as Id<"_storage">;
break;
} }
} }
} }
const promptToUse = (connectedTextPrompt ?? prompt).trim();
if (!promptToUse) return;
const currentNode = getNode(id); const currentNode = getNode(id);
const offsetX = (currentNode?.measured?.width ?? 280) + 32; const offsetX = (currentNode?.measured?.width ?? 280) + 32;
const posX = (currentNode?.position?.x ?? 0) + offsetX; const posX = (currentNode?.position?.x ?? 0) + offsetX;
@@ -146,7 +189,7 @@ export default function PromptNode({
width: outer.width, width: outer.width,
height: outer.height, height: outer.height,
data: { data: {
prompt, prompt: promptToUse,
model: DEFAULT_MODEL_ID, model: DEFAULT_MODEL_ID,
modelTier: "standard", modelTier: "standard",
canvasId, canvasId,
@@ -167,18 +210,19 @@ export default function PromptNode({
await generateImage({ await generateImage({
canvasId, canvasId,
nodeId: aiNodeId, nodeId: aiNodeId,
prompt, prompt: promptToUse,
referenceStorageId, referenceStorageId,
model: DEFAULT_MODEL_ID, model: DEFAULT_MODEL_ID,
aspectRatio, aspectRatio,
}); });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Generation failed"); setError(err instanceof Error ? err.message : "Bildgenerierung fehlgeschlagen");
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}, [ }, [
prompt, prompt,
effectivePrompt,
aspectRatio, aspectRatio,
isGenerating, isGenerating,
nodeData.canvasId, nodeData.canvasId,
@@ -206,15 +250,26 @@ export default function PromptNode({
<div className="flex flex-col gap-2 p-3"> <div className="flex flex-col gap-2 p-3">
<div className="text-xs font-medium text-violet-600 dark:text-violet-400"> <div className="text-xs font-medium text-violet-600 dark:text-violet-400">
Prompt Eingabe
</div> </div>
{inputMeta.hasTextInput ? (
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
<p className="text-[11px] font-medium text-violet-700 dark:text-violet-300">
Prompt aus verbundener Text-Node
</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-foreground">
{inputMeta.textPrompt.trim() || "(Verbundene Text-Node ist leer)"}
</p>
</div>
) : (
<textarea <textarea
value={prompt} value={prompt}
onChange={handlePromptChange} onChange={handlePromptChange}
placeholder="Describe what you want to generate…" placeholder="Beschreibe, was du generieren willst…"
rows={4} rows={4}
className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500" className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
/> />
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label <Label
@@ -262,18 +317,18 @@ export default function PromptNode({
<button <button
type="button" type="button"
onClick={() => void handleGenerate()} onClick={() => void handleGenerate()}
disabled={!prompt.trim() || isGenerating} disabled={!effectivePrompt.trim() || isGenerating}
className="nodrag flex items-center justify-center gap-2 rounded-md bg-violet-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50" className="nodrag flex items-center justify-center gap-2 rounded-md bg-violet-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Generating Generiere
</> </>
) : ( ) : (
<> <>
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Generate Image Bild generieren
</> </>
)} )}
</button> </button>

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import {
Handle,
Position,
useReactFlow,
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -17,6 +23,7 @@ type TextNodeData = {
export type TextNode = Node<TextNodeData, "text">; export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) { export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
const { setNodes } = useReactFlow();
const updateData = useMutation(api.nodes.updateData); const updateData = useMutation(api.nodes.updateData);
const [content, setContent] = useState(data.content ?? ""); const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -24,6 +31,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
// Sync von außen (Convex-Update) wenn nicht gerade editiert wird // Sync von außen (Convex-Update) wenn nicht gerade editiert wird
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setContent(data.content ?? ""); setContent(data.content ?? "");
} }
}, [data.content, isEditing]); }, [data.content, isEditing]);
@@ -48,9 +56,22 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
(e: React.ChangeEvent<HTMLTextAreaElement>) => { (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value; const newContent = e.target.value;
setContent(newContent); setContent(newContent);
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
content: newContent,
},
}
: node,
),
);
saveContent(newContent); saveContent(newContent);
}, },
[saveContent], [id, saveContent, setNodes],
); );
return ( return (

View File

@@ -82,15 +82,38 @@ export const remove = mutation({
args: { edgeId: v.id("edges") }, args: { edgeId: v.id("edges") },
handler: async (ctx, { edgeId }) => { handler: async (ctx, { edgeId }) => {
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
console.info("[edges.remove] request", {
edgeId,
userId: user.userId,
});
const edge = await ctx.db.get(edgeId); const edge = await ctx.db.get(edgeId);
if (!edge) throw new Error("Edge not found"); if (!edge) {
console.info("[edges.remove] edge already removed (idempotent no-op)", {
edgeId,
userId: user.userId,
});
return;
}
const canvas = await ctx.db.get(edge.canvasId); const canvas = await ctx.db.get(edge.canvasId);
if (!canvas || canvas.ownerId !== user.userId) { if (!canvas || canvas.ownerId !== user.userId) {
console.warn("[edges.remove] unauthorized canvas access", {
edgeId,
canvasId: edge.canvasId,
userId: user.userId,
hasCanvas: Boolean(canvas),
});
throw new Error("Canvas not found"); throw new Error("Canvas not found");
} }
await ctx.db.delete(edgeId); await ctx.db.delete(edgeId);
await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() }); await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() });
console.info("[edges.remove] success", {
edgeId,
canvasId: edge.canvasId,
userId: user.userId,
});
}, },
}); });

View File

@@ -19,11 +19,11 @@ export const IMAGE_FORMAT_GROUP_LABELS: Record<ImageFormatGroup, string> = {
export const IMAGE_FORMAT_PRESETS: ImageFormatPreset[] = [ export const IMAGE_FORMAT_PRESETS: ImageFormatPreset[] = [
{ label: "1:1 · Quadrat", aspectRatio: "1:1", group: "square" }, { label: "1:1 · Quadrat", aspectRatio: "1:1", group: "square" },
{ label: "16:9 · Breitbild", aspectRatio: "16:9", group: "landscape" }, { label: "16:9 · Breitbild", aspectRatio: "16:9", group: "landscape" },
{ label: "21:9 · Cinematic", aspectRatio: "21:9", group: "landscape" }, { label: "21:9 · Kino", aspectRatio: "21:9", group: "landscape" },
{ label: "4:3 · Klassisch", aspectRatio: "4:3", group: "landscape" }, { label: "4:3 · Klassisch", aspectRatio: "4:3", group: "landscape" },
{ label: "3:2 · Foto (quer)", aspectRatio: "3:2", group: "landscape" }, { label: "3:2 · Foto (quer)", aspectRatio: "3:2", group: "landscape" },
{ label: "5:4 · leicht quer", aspectRatio: "5:4", group: "landscape" }, { label: "5:4 · leicht quer", aspectRatio: "5:4", group: "landscape" },
{ label: "9:16 · Stories", aspectRatio: "9:16", group: "portrait" }, { label: "9:16 · Story", aspectRatio: "9:16", group: "portrait" },
{ label: "3:4 · Porträt", aspectRatio: "3:4", group: "portrait" }, { label: "3:4 · Porträt", aspectRatio: "3:4", group: "portrait" },
{ label: "2:3 · Foto (hoch)", aspectRatio: "2:3", group: "portrait" }, { label: "2:3 · Foto (hoch)", aspectRatio: "2:3", group: "portrait" },
{ label: "4:5 · Social hoch", aspectRatio: "4:5", group: "portrait" }, { label: "4:5 · Social hoch", aspectRatio: "4:5", group: "portrait" },