feat: enhance image handling and node deletion logic in canvas

- Introduced a new function to determine acceptable geometry for node deletion, improving synchronization checks with Convex.
- Added image dimension retrieval for uploaded files, enhancing the handling of image nodes during drag-and-drop operations.
- Updated drag-and-drop functionality to support image uploads, including error handling and user feedback for failed uploads.
- Refactored existing logic to ensure better management of optimistic node states and improve overall user experience on the canvas.
This commit is contained in:
Matthias
2026-03-28 23:42:12 +01:00
parent 2a645b9489
commit 75ed27b1c3
2 changed files with 111 additions and 4 deletions

View File

@@ -114,6 +114,26 @@ function isNodeGeometrySyncedWithConvex(
);
}
/** Für Delete-Guard: ausreichend sync, wenn Löschen in Convex sicher ist (kein laufendes Move/Resize). */
function isNodeDeleteGeometryAcceptable(
node: RFNode,
doc: Doc<"nodes">,
): boolean {
if (isNodeGeometrySyncedWithConvex(node, doc)) return true;
const posEq =
node.position.x === doc.positionX &&
node.position.y === doc.positionY;
if (!posEq) return false;
const isMedia =
node.type === "asset" ||
node.type === "image" ||
node.type === "ai-image";
// mergeNodesPreservingLocalState: ausgewählte Media-Nodes behalten oft Platzhalter-Maße in style,
// während Convex bereits echte Breite/Höhe hat — Position ist mit dem Server abgeglichen, Löschen ist ok.
if (isMedia && Boolean(node.selected)) return true;
return false;
}
function getNodeDeleteBlockReason(
node: RFNode,
convexById: Map<string, Doc<"nodes">>,
@@ -121,7 +141,7 @@ function getNodeDeleteBlockReason(
if (isOptimisticNodeId(node.id)) return "optimistic";
const doc = convexById.get(node.id);
if (!doc) return "missingInConvex";
if (!isNodeGeometrySyncedWithConvex(node, doc)) return "geometryPending";
if (!isNodeDeleteGeometryAcceptable(node, doc)) return "geometryPending";
return null;
}
@@ -515,6 +535,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize);
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const batchMoveNodes = useMutation(api.nodes.batchMove);
const pendingMoveAfterCreateRef = useRef(
new Map<string, { positionX: number; positionY: number }>(),
@@ -1935,19 +1956,100 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[removeEdge],
);
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new window.Image();
image.onload = () => {
const width = image.naturalWidth;
const height = image.naturalHeight;
URL.revokeObjectURL(objectUrl);
if (!width || !height) {
reject(new Error("Could not read image dimensions"));
return;
}
resolve({ width, height });
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("Could not decode image"));
};
image.src = objectUrl;
});
}
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const hasFiles = event.dataTransfer.types.includes("Files");
event.dataTransfer.dropEffect = hasFiles ? "copy" : "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
async (event: React.DragEvent) => {
event.preventDefault();
const rawData = event.dataTransfer.getData(
"application/lemonspace-node-type",
);
if (!rawData) {
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;
if (hasFiles) {
const file = event.dataTransfer.files[0];
if (file.type.startsWith("image/")) {
try {
let dimensions: { width: number; height: number } | undefined;
try {
dimensions = await getImageDimensions(file);
} catch {
dimensions = undefined;
}
const uploadUrl = await generateUploadUrl();
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Upload failed");
}
const { storageId } = (await result.json()) as { storageId: string };
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const clientRequestId = crypto.randomUUID();
void createNode({
canvasId,
type: "image",
positionX: position.x,
positionY: position.y,
width: NODE_DEFAULTS.image.width,
height: NODE_DEFAULTS.image.height,
data: {
storageId,
filename: file.name,
mimeType: file.type,
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
canvasId,
},
clientRequestId,
}).then((realId) => {
syncPendingMoveForClientRequest(clientRequestId, realId);
});
} catch (err) {
console.error("Failed to upload dropped file:", err);
toast.error(msg.canvas.uploadFailed.title, err instanceof Error ? err.message : undefined);
}
return;
}
}
return;
}
@@ -1992,7 +2094,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
syncPendingMoveForClientRequest(clientRequestId, realId);
});
},
[screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest],
[screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest, generateUploadUrl],
);
// ─── Scherenmodus (K) — Kante klicken oder mit Maus durchschneiden ─

View File

@@ -23,6 +23,7 @@ const ALLOWED_IMAGE_TYPES = new Set([
"image/webp",
]);
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
type ImageNodeData = {
storageId?: string;
@@ -80,6 +81,10 @@ export default function ImageNode({
const hasAutoSizedRef = useRef(false);
useEffect(() => {
if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) {
return;
}
if (typeof data.width !== "number" || typeof data.height !== "number") {
return;
}