feat: enhance canvas operations with local persistence and optimistic updates
- Introduced local persistence for canvas state, enabling snapshot reading and writing for improved user experience during interactions. - Added new functions for handling node movement, resizing, and edge creation/removal with optimistic updates, ensuring immediate feedback in the UI. - Refactored existing logic to streamline node and edge operations, enhancing overall responsiveness and synchronization with the server.
This commit is contained in:
@@ -35,6 +35,12 @@ import { cn } from "@/lib/utils";
|
|||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||||
|
import {
|
||||||
|
enqueueCanvasOp,
|
||||||
|
readCanvasSnapshot,
|
||||||
|
resolveCanvasOp,
|
||||||
|
writeCanvasSnapshot,
|
||||||
|
} from "@/lib/canvas-local-persistence";
|
||||||
|
|
||||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -82,6 +88,13 @@ interface CanvasInnerProps {
|
|||||||
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
|
|
||||||
|
function createCanvasOpId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `op_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** @xyflow/react default minZoom ist 0.5 — dreimal weiter raus für große Boards. */
|
/** @xyflow/react default minZoom ist 0.5 — dreimal weiter raus für große Boards. */
|
||||||
const CANVAS_MIN_ZOOM = 0.5 / 3;
|
const CANVAS_MIN_ZOOM = 0.5 / 3;
|
||||||
|
|
||||||
@@ -118,50 +131,10 @@ function rfEdgeConnectionSignature(edge: RFEdge): string {
|
|||||||
return `${edge.source}|${edge.target}|${sanitizeHandleForEdgeSignature(edge.sourceHandle)}|${sanitizeHandleForEdgeSignature(edge.targetHandle)}`;
|
return `${edge.source}|${edge.target}|${sanitizeHandleForEdgeSignature(edge.sourceHandle)}|${sanitizeHandleForEdgeSignature(edge.targetHandle)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNodeGeometrySyncedWithConvex(
|
|
||||||
node: RFNode,
|
|
||||||
doc: Doc<"nodes">,
|
|
||||||
): boolean {
|
|
||||||
const styleW = node.style?.width;
|
|
||||||
const styleH = node.style?.height;
|
|
||||||
const w = typeof styleW === "number" ? styleW : doc.width;
|
|
||||||
const h = typeof styleH === "number" ? styleH : doc.height;
|
|
||||||
return (
|
|
||||||
node.position.x === doc.positionX &&
|
|
||||||
node.position.y === doc.positionY &&
|
|
||||||
w === doc.width &&
|
|
||||||
h === doc.height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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">>,
|
|
||||||
): CanvasNodeDeleteBlockReason | null {
|
): CanvasNodeDeleteBlockReason | null {
|
||||||
if (isOptimisticNodeId(node.id)) return "optimistic";
|
if (isOptimisticNodeId(node.id)) return "optimistic";
|
||||||
const doc = convexById.get(node.id);
|
|
||||||
if (!doc) return "missingInConvex";
|
|
||||||
if (!isNodeDeleteGeometryAcceptable(node, doc)) return "geometryPending";
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,6 +819,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const runMoveNodeMutation = useCallback(
|
||||||
|
async (args: { nodeId: Id<"nodes">; positionX: number; positionY: number }) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "moveNode", payload: args });
|
||||||
|
try {
|
||||||
|
return await moveNode(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canvasId, moveNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runBatchMoveNodesMutation = useCallback(
|
||||||
|
async (args: Parameters<typeof batchMoveNodes>[0]) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "batchMoveNodes", payload: args });
|
||||||
|
try {
|
||||||
|
return await batchMoveNodes(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchMoveNodes, canvasId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runResizeNodeMutation = useCallback(
|
||||||
|
async (args: { nodeId: Id<"nodes">; width: number; height: number }) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "resizeNode", payload: args });
|
||||||
|
try {
|
||||||
|
return await resizeNode(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canvasId, resizeNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runBatchRemoveNodesMutation = useCallback(
|
||||||
|
async (args: Parameters<typeof batchRemoveNodes>[0]) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "batchRemoveNodes", payload: args });
|
||||||
|
try {
|
||||||
|
return await batchRemoveNodes(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchRemoveNodes, canvasId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCreateEdgeMutation = useCallback(
|
||||||
|
async (args: Parameters<typeof createEdge>[0]) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "createEdge", payload: args });
|
||||||
|
try {
|
||||||
|
return await createEdge(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canvasId, createEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runRemoveEdgeMutation = useCallback(
|
||||||
|
async (args: Parameters<typeof removeEdge>[0]) => {
|
||||||
|
const opId = createCanvasOpId();
|
||||||
|
enqueueCanvasOp(canvasId, { id: opId, type: "removeEdge", payload: args });
|
||||||
|
try {
|
||||||
|
return await removeEdge(args);
|
||||||
|
} finally {
|
||||||
|
resolveCanvasOp(canvasId, opId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canvasId, removeEdge],
|
||||||
|
);
|
||||||
|
|
||||||
const splitEdgeAtExistingNodeMut = useMutation(
|
const splitEdgeAtExistingNodeMut = useMutation(
|
||||||
api.nodes.splitEdgeAtExistingNode,
|
api.nodes.splitEdgeAtExistingNode,
|
||||||
).withOptimisticUpdate((localStore, args) => {
|
).withOptimisticUpdate((localStore, args) => {
|
||||||
@@ -976,7 +1027,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
y: pendingMove.positionY,
|
y: pendingMove.positionY,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await moveNode({
|
await runMoveNodeMutation({
|
||||||
nodeId: realId,
|
nodeId: realId,
|
||||||
positionX: pendingMove.positionX,
|
positionX: pendingMove.positionX,
|
||||||
positionY: pendingMove.positionY,
|
positionY: pendingMove.positionY,
|
||||||
@@ -1022,19 +1073,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
x: p.positionX,
|
x: p.positionX,
|
||||||
y: p.positionY,
|
y: p.positionY,
|
||||||
});
|
});
|
||||||
await moveNode({
|
await runMoveNodeMutation({
|
||||||
nodeId: r,
|
nodeId: r,
|
||||||
positionX: p.positionX,
|
positionX: p.positionX,
|
||||||
positionY: p.positionY,
|
positionY: p.positionY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canvasId, moveNode, splitEdgeAtExistingNodeMut],
|
[canvasId, runMoveNodeMutation, splitEdgeAtExistingNodeMut],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false);
|
||||||
/** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */
|
/** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */
|
||||||
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
|
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
|
||||||
const [connectionDropMenu, setConnectionDropMenu] =
|
const [connectionDropMenu, setConnectionDropMenu] =
|
||||||
@@ -1048,6 +1100,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
>(null);
|
>(null);
|
||||||
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
|
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const snapshot = readCanvasSnapshot<RFNode, RFEdge>(canvasId as string);
|
||||||
|
if (snapshot) {
|
||||||
|
setNodes(snapshot.nodes);
|
||||||
|
setEdges(snapshot.edges);
|
||||||
|
}
|
||||||
|
setHasHydratedLocalSnapshot(true);
|
||||||
|
}, [canvasId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasHydratedLocalSnapshot) return;
|
||||||
|
writeCanvasSnapshot(canvasId as string, { nodes, edges });
|
||||||
|
}, [canvasId, edges, hasHydratedLocalSnapshot, nodes]);
|
||||||
|
|
||||||
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
|
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
|
||||||
if (tool === "scissor") {
|
if (tool === "scissor") {
|
||||||
setScissorsMode(true);
|
setScissorsMode(true);
|
||||||
@@ -1646,7 +1712,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
void nextNode;
|
void nextNode;
|
||||||
if (change.resizing !== false) continue;
|
if (change.resizing !== false) continue;
|
||||||
|
|
||||||
void resizeNode({
|
void runResizeNodeMutation({
|
||||||
nodeId: change.id as Id<"nodes">,
|
nodeId: change.id as Id<"nodes">,
|
||||||
width: change.dimensions.width,
|
width: change.dimensions.width,
|
||||||
height: change.dimensions.height,
|
height: change.dimensions.height,
|
||||||
@@ -1660,7 +1726,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return nextNodes;
|
return nextNodes;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[resizeNode],
|
[runResizeNodeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
||||||
@@ -1700,7 +1766,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
console.error("[Canvas edge remove failed] reconnect end", {
|
console.error("[Canvas edge remove failed] reconnect end", {
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
edgeClassName: edge.className ?? null,
|
edgeClassName: edge.className ?? null,
|
||||||
@@ -1715,7 +1781,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
isReconnectDragActiveRef.current = false;
|
isReconnectDragActiveRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[runRemoveEdgeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => {
|
const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => {
|
||||||
@@ -1863,7 +1929,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id));
|
const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id));
|
||||||
if (realMoves.length > 0) {
|
if (realMoves.length > 0) {
|
||||||
await batchMoveNodes({
|
await runBatchMoveNodesMutation({
|
||||||
moves: realMoves.map((n) => ({
|
moves: realMoves.map((n) => ({
|
||||||
nodeId: n.id as Id<"nodes">,
|
nodeId: n.id as Id<"nodes">,
|
||||||
positionX: n.position.x,
|
positionX: n.position.x,
|
||||||
@@ -1922,7 +1988,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
await syncPendingMoveForClientRequest(cidSingle);
|
await syncPendingMoveForClientRequest(cidSingle);
|
||||||
} else {
|
} else {
|
||||||
await moveNode({
|
await runMoveNodeMutation({
|
||||||
nodeId: node.id as Id<"nodes">,
|
nodeId: node.id as Id<"nodes">,
|
||||||
positionX: node.position.x,
|
positionX: node.position.x,
|
||||||
positionY: node.position.y,
|
positionY: node.position.y,
|
||||||
@@ -2000,10 +2066,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
batchMoveNodes,
|
|
||||||
canvasId,
|
canvasId,
|
||||||
edges,
|
edges,
|
||||||
moveNode,
|
runBatchMoveNodesMutation,
|
||||||
|
runMoveNodeMutation,
|
||||||
setHighlightedIntersectionEdge,
|
setHighlightedIntersectionEdge,
|
||||||
splitEdgeAtExistingNodeMut,
|
splitEdgeAtExistingNodeMut,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
@@ -2014,7 +2080,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
if (connection.source && connection.target) {
|
if (connection.source && connection.target) {
|
||||||
createEdge({
|
void runCreateEdgeMutation({
|
||||||
canvasId,
|
canvasId,
|
||||||
sourceNodeId: connection.source as Id<"nodes">,
|
sourceNodeId: connection.source as Id<"nodes">,
|
||||||
targetNodeId: connection.target as Id<"nodes">,
|
targetNodeId: connection.target as Id<"nodes">,
|
||||||
@@ -2023,7 +2089,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createEdge, canvasId],
|
[canvasId, runCreateEdgeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
@@ -2151,15 +2217,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const convexById = new Map<string, Doc<"nodes">>(
|
|
||||||
(convexNodes ?? []).map((n) => [n._id as string, n]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allowed: RFNode[] = [];
|
const allowed: RFNode[] = [];
|
||||||
const blocked: RFNode[] = [];
|
const blocked: RFNode[] = [];
|
||||||
const blockedReasons = new Set<CanvasNodeDeleteBlockReason>();
|
const blockedReasons = new Set<CanvasNodeDeleteBlockReason>();
|
||||||
for (const node of matchingNodes) {
|
for (const node of matchingNodes) {
|
||||||
const reason = getNodeDeleteBlockReason(node, convexById);
|
const reason = getNodeDeleteBlockReason(node);
|
||||||
if (reason !== null) {
|
if (reason !== null) {
|
||||||
blocked.push(node);
|
blocked.push(node);
|
||||||
blockedReasons.add(reason);
|
blockedReasons.add(reason);
|
||||||
@@ -2188,7 +2250,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[convexNodes],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Node löschen → Convex ────────────────────────────────────
|
// ─── Node löschen → Convex ────────────────────────────────────
|
||||||
@@ -2214,7 +2276,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
edges,
|
edges,
|
||||||
);
|
);
|
||||||
const edgePromises = bridgeCreates.map((b) =>
|
const edgePromises = bridgeCreates.map((b) =>
|
||||||
createEdge({
|
runCreateEdgeMutation({
|
||||||
canvasId,
|
canvasId,
|
||||||
sourceNodeId: b.sourceNodeId,
|
sourceNodeId: b.sourceNodeId,
|
||||||
targetNodeId: b.targetNodeId,
|
targetNodeId: b.targetNodeId,
|
||||||
@@ -2225,7 +2287,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen
|
// Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
batchRemoveNodes({
|
runBatchRemoveNodesMutation({
|
||||||
nodeIds: idsToDelete as Id<"nodes">[],
|
nodeIds: idsToDelete as Id<"nodes">[],
|
||||||
}),
|
}),
|
||||||
...edgePromises,
|
...edgePromises,
|
||||||
@@ -2248,7 +2310,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
toast.info(title);
|
toast.info(title);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodes, edges, batchRemoveNodes, createEdge, canvasId],
|
[nodes, edges, runBatchRemoveNodesMutation, runCreateEdgeMutation, canvasId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Edge löschen → Convex ────────────────────────────────────
|
// ─── Edge löschen → Convex ────────────────────────────────────
|
||||||
@@ -2263,7 +2325,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
console.error("[Canvas edge remove failed] edge delete", {
|
console.error("[Canvas edge remove failed] edge delete", {
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
edgeClassName: edge.className ?? null,
|
edgeClassName: edge.className ?? null,
|
||||||
@@ -2274,7 +2336,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[runRemoveEdgeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
|
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||||
@@ -2469,14 +2531,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
(_event: ReactMouseEvent, edge: RFEdge) => {
|
(_event: ReactMouseEvent, edge: RFEdge) => {
|
||||||
if (!scissorsModeRef.current) return;
|
if (!scissorsModeRef.current) return;
|
||||||
if (!isEdgeCuttable(edge)) return;
|
if (!isEdgeCuttable(edge)) return;
|
||||||
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
console.error("[Canvas] scissors edge click remove failed", {
|
console.error("[Canvas] scissors edge click remove failed", {
|
||||||
edgeId: edge.id,
|
edgeId: edge.id,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[runRemoveEdgeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScissorsFlowPointerDownCapture = useCallback(
|
const onScissorsFlowPointerDownCapture = useCallback(
|
||||||
@@ -2522,7 +2584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setScissorStrokePreview(null);
|
setScissorStrokePreview(null);
|
||||||
if (!scissorsModeRef.current) return;
|
if (!scissorsModeRef.current) return;
|
||||||
for (const id of strokeIds) {
|
for (const id of strokeIds) {
|
||||||
void removeEdge({ edgeId: id as Id<"edges"> }).catch((error) => {
|
void runRemoveEdgeMutation({ edgeId: id as Id<"edges"> }).catch((error) => {
|
||||||
console.error("[Canvas] scissors stroke remove failed", {
|
console.error("[Canvas] scissors stroke remove failed", {
|
||||||
edgeId: id,
|
edgeId: id,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
@@ -2536,7 +2598,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
window.addEventListener("pointercancel", handleUp);
|
window.addEventListener("pointercancel", handleUp);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[runRemoveEdgeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Loading State ────────────────────────────────────────────
|
// ─── Loading State ────────────────────────────────────────────
|
||||||
|
|||||||
167
lib/canvas-local-persistence.ts
Normal file
167
lib/canvas-local-persistence.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
const STORAGE_NAMESPACE = "lemonspace.canvas";
|
||||||
|
const SNAPSHOT_VERSION = 1;
|
||||||
|
const OPS_VERSION = 1;
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type CanvasSnapshotPayload<TNode, TEdge> = {
|
||||||
|
version: number;
|
||||||
|
updatedAt: number;
|
||||||
|
nodes: TNode[];
|
||||||
|
edges: TEdge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CanvasOpQueuePayload = {
|
||||||
|
version: number;
|
||||||
|
updatedAt: number;
|
||||||
|
ops: CanvasPendingOp[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasPendingOp = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
enqueuedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStorage(): Storage | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(raw: string | null): unknown {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotKey(canvasId: string): string {
|
||||||
|
return `${STORAGE_NAMESPACE}:snapshot:v${SNAPSHOT_VERSION}:${canvasId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsKey(canvasId: string): string {
|
||||||
|
return `${STORAGE_NAMESPACE}:ops:v${OPS_VERSION}:${canvasId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSnapshotPayload<TNode, TEdge>(
|
||||||
|
canvasId: string,
|
||||||
|
): CanvasSnapshotPayload<TNode, TEdge> | null {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
const parsed = safeParse(storage.getItem(snapshotKey(canvasId)));
|
||||||
|
if (!isRecord(parsed)) return null;
|
||||||
|
const version = parsed.version;
|
||||||
|
const nodes = parsed.nodes;
|
||||||
|
const edges = parsed.edges;
|
||||||
|
if (version !== SNAPSHOT_VERSION) return null;
|
||||||
|
if (!Array.isArray(nodes) || !Array.isArray(edges)) return null;
|
||||||
|
return {
|
||||||
|
version: SNAPSHOT_VERSION,
|
||||||
|
updatedAt:
|
||||||
|
typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
|
||||||
|
nodes: nodes as TNode[],
|
||||||
|
edges: edges as TEdge[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpsPayload(canvasId: string): CanvasOpQueuePayload {
|
||||||
|
const fallback: CanvasOpQueuePayload = {
|
||||||
|
version: OPS_VERSION,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
ops: [],
|
||||||
|
};
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return fallback;
|
||||||
|
const parsed = safeParse(storage.getItem(opsKey(canvasId)));
|
||||||
|
if (!isRecord(parsed)) return fallback;
|
||||||
|
if (parsed.version !== OPS_VERSION || !Array.isArray(parsed.ops)) return fallback;
|
||||||
|
|
||||||
|
const ops = parsed.ops
|
||||||
|
.filter((op): op is JsonRecord => isRecord(op))
|
||||||
|
.filter(
|
||||||
|
(op) =>
|
||||||
|
typeof op.id === "string" &&
|
||||||
|
op.id.length > 0 &&
|
||||||
|
typeof op.type === "string" &&
|
||||||
|
op.type.length > 0,
|
||||||
|
)
|
||||||
|
.map((op) => ({
|
||||||
|
id: op.id as string,
|
||||||
|
type: op.type as string,
|
||||||
|
payload: op.payload,
|
||||||
|
enqueuedAt:
|
||||||
|
typeof op.enqueuedAt === "number" ? op.enqueuedAt : Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: OPS_VERSION,
|
||||||
|
updatedAt:
|
||||||
|
typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
|
||||||
|
ops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePayload(key: string, value: unknown): void {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/storage write failures in UX cache layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCanvasSnapshot<TNode = unknown, TEdge = unknown>(
|
||||||
|
canvasId: string,
|
||||||
|
): { nodes: TNode[]; edges: TEdge[] } | null {
|
||||||
|
const parsed = readSnapshotPayload<TNode, TEdge>(canvasId);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return { nodes: parsed.nodes, edges: parsed.edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCanvasSnapshot<TNode, TEdge>(
|
||||||
|
canvasId: string,
|
||||||
|
snapshot: { nodes: TNode[]; edges: TEdge[] },
|
||||||
|
): void {
|
||||||
|
writePayload(snapshotKey(canvasId), {
|
||||||
|
version: SNAPSHOT_VERSION,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
nodes: snapshot.nodes,
|
||||||
|
edges: snapshot.edges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueCanvasOp(
|
||||||
|
canvasId: string,
|
||||||
|
op: Omit<CanvasPendingOp, "enqueuedAt"> & { enqueuedAt?: number },
|
||||||
|
): string {
|
||||||
|
const entry: CanvasPendingOp = {
|
||||||
|
...op,
|
||||||
|
enqueuedAt: op.enqueuedAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
const payload = readOpsPayload(canvasId);
|
||||||
|
payload.ops.push(entry);
|
||||||
|
payload.updatedAt = Date.now();
|
||||||
|
writePayload(opsKey(canvasId), payload);
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCanvasOp(canvasId: string, opId: string): void {
|
||||||
|
const payload = readOpsPayload(canvasId);
|
||||||
|
const nextOps = payload.ops.filter((op) => op.id !== opId);
|
||||||
|
if (nextOps.length === payload.ops.length) return;
|
||||||
|
payload.ops = nextOps;
|
||||||
|
payload.updatedAt = Date.now();
|
||||||
|
writePayload(opsKey(canvasId), payload);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
// Zentrales Dictionary für alle Toast-Strings.
|
// Zentrales Dictionary für alle Toast-Strings.
|
||||||
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
|
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
|
||||||
|
|
||||||
/** Grund, warum ein Node-Löschen bis zur Convex-Synchronisierung blockiert ist. */
|
/** Grund, warum ein Node-Löschen noch blockiert ist. */
|
||||||
export type CanvasNodeDeleteBlockReason =
|
export type CanvasNodeDeleteBlockReason = "optimistic";
|
||||||
| "optimistic"
|
|
||||||
| "missingInConvex"
|
|
||||||
| "geometryPending";
|
|
||||||
|
|
||||||
function canvasNodeDeleteWhy(
|
function canvasNodeDeleteWhy(
|
||||||
reasons: Set<CanvasNodeDeleteBlockReason>,
|
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||||
@@ -24,20 +21,14 @@ function canvasNodeDeleteWhy(
|
|||||||
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
|
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (only === "missingInConvex") {
|
|
||||||
return {
|
|
||||||
title: "Element noch nicht verfügbar",
|
|
||||||
desc: "Dieses Element ist in der Datenbank noch nicht sichtbar. Warte einen Moment und versuche das Löschen erneut.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title: "Änderungen werden gespeichert",
|
title: "Löschen momentan nicht möglich",
|
||||||
desc: "Position oder Größe ist noch nicht mit dem Server abgeglichen — zum Beispiel direkt nach Verschieben oder nach dem Ziehen an der Größe. Bitte kurz warten.",
|
desc: "Bitte kurz warten und erneut versuchen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: "Löschen momentan nicht möglich",
|
title: "Löschen momentan nicht möglich",
|
||||||
desc: "Mindestens ein Element wird noch angelegt, oder Position bzw. Größe wird noch gespeichert. Bitte kurz warten und erneut versuchen.",
|
desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user