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:
Matthias
2026-03-29 21:21:39 +02:00
parent 5d4ddd3f78
commit 81f0b1d7a3
3 changed files with 302 additions and 82 deletions

View File

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

View 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);
}

View File

@@ -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.",
}; };
} }