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:
@@ -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(
|
function getNodeDeleteBlockReason(
|
||||||
node: RFNode,
|
node: RFNode,
|
||||||
convexById: Map<string, Doc<"nodes">>,
|
convexById: Map<string, Doc<"nodes">>,
|
||||||
@@ -121,7 +141,7 @@ function getNodeDeleteBlockReason(
|
|||||||
if (isOptimisticNodeId(node.id)) return "optimistic";
|
if (isOptimisticNodeId(node.id)) return "optimistic";
|
||||||
const doc = convexById.get(node.id);
|
const doc = convexById.get(node.id);
|
||||||
if (!doc) return "missingInConvex";
|
if (!doc) return "missingInConvex";
|
||||||
if (!isNodeGeometrySyncedWithConvex(node, doc)) return "geometryPending";
|
if (!isNodeDeleteGeometryAcceptable(node, doc)) return "geometryPending";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +535,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
|
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
|
||||||
const moveNode = useMutation(api.nodes.move);
|
const moveNode = useMutation(api.nodes.move);
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
|
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||||
const batchMoveNodes = useMutation(api.nodes.batchMove);
|
const batchMoveNodes = useMutation(api.nodes.batchMove);
|
||||||
const pendingMoveAfterCreateRef = useRef(
|
const pendingMoveAfterCreateRef = useRef(
|
||||||
new Map<string, { positionX: number; positionY: number }>(),
|
new Map<string, { positionX: number; positionY: number }>(),
|
||||||
@@ -1935,19 +1956,100 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
[removeEdge],
|
[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) => {
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||||
|
event.dataTransfer.dropEffect = hasFiles ? "copy" : "move";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: React.DragEvent) => {
|
async (event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const rawData = event.dataTransfer.getData(
|
const rawData = event.dataTransfer.getData(
|
||||||
"application/lemonspace-node-type",
|
"application/lemonspace-node-type",
|
||||||
);
|
);
|
||||||
if (!rawData) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1992,7 +2094,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
syncPendingMoveForClientRequest(clientRequestId, realId);
|
syncPendingMoveForClientRequest(clientRequestId, realId);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest],
|
[screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest, generateUploadUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Scherenmodus (K) — Kante klicken oder mit Maus durchschneiden ─
|
// ─── Scherenmodus (K) — Kante klicken oder mit Maus durchschneiden ─
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ALLOWED_IMAGE_TYPES = new Set([
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
]);
|
]);
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
|
|
||||||
type ImageNodeData = {
|
type ImageNodeData = {
|
||||||
storageId?: string;
|
storageId?: string;
|
||||||
@@ -80,6 +81,10 @@ export default function ImageNode({
|
|||||||
const hasAutoSizedRef = useRef(false);
|
const hasAutoSizedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user