perf(collab): reduce cursor and preview update churn

This commit is contained in:
2026-02-14 16:38:40 +01:00
parent 0ffe410eeb
commit 5d613ea550
3 changed files with 284 additions and 57 deletions
+59 -1
View File
@@ -23,6 +23,25 @@ type RegisterSocketHandlersDeps = {
collabSessionManager: CollabSessionManager; collabSessionManager: CollabSessionManager;
}; };
type CursorGuardState = {
lastAt: number;
lastSignature: string;
};
const CURSOR_MIN_INTERVAL_MS = 45;
const CURSOR_DUPLICATE_WINDOW_MS = 180;
const toCursorSignature = (
pointer: { x: number; y: number },
button: string,
selectedElementIds?: Record<string, boolean>
): string => {
const roundedX = Math.round(pointer.x * 10) / 10;
const roundedY = Math.round(pointer.y * 10) / 10;
const selectionKeys = Object.keys(selectedElementIds || {}).sort();
return `${roundedX},${roundedY}|${button}|${selectionKeys.join(",")}`;
};
export const registerSocketHandlers = ({ export const registerSocketHandlers = ({
io, io,
prisma, prisma,
@@ -115,6 +134,7 @@ export const registerSocketHandlers = ({
io.on("connection", (socket) => { io.on("connection", (socket) => {
const authenticatedUserId = socketUserMap.get(socket.id); const authenticatedUserId = socketUserMap.get(socket.id);
const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">(); const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">();
const cursorGuardByDrawing = new Map<string, CursorGuardState>();
socket.on( socket.on(
"join-room", "join-room",
@@ -194,8 +214,46 @@ export const registerSocketHandlers = ({
if (!drawingId || !authorizedDrawingRoles.has(drawingId)) { if (!drawingId || !authorizedDrawingRoles.has(drawingId)) {
return; return;
} }
const pointerX = Number(data?.pointer?.x);
const pointerY = Number(data?.pointer?.y);
if (!Number.isFinite(pointerX) || !Number.isFinite(pointerY)) {
return;
}
const pointer = { x: pointerX, y: pointerY };
const button = typeof data?.button === "string" ? data.button : "up";
const selectedElementIds =
data?.selectedElementIds && typeof data.selectedElementIds === "object"
? (data.selectedElementIds as Record<string, boolean>)
: undefined;
const signature = toCursorSignature(pointer, button, selectedElementIds);
const now = Date.now();
const guardState = cursorGuardByDrawing.get(drawingId);
if (guardState) {
if (now - guardState.lastAt < CURSOR_MIN_INTERVAL_MS) {
return;
}
if (
signature === guardState.lastSignature &&
now - guardState.lastAt < CURSOR_DUPLICATE_WINDOW_MS
) {
return;
}
}
cursorGuardByDrawing.set(drawingId, { lastAt: now, lastSignature: signature });
const payload: Record<string, unknown> = {
drawingId,
pointer,
button,
userId: data?.userId,
username: data?.username,
color: data?.color,
};
if (selectedElementIds) {
payload.selectedElementIds = selectedElementIds;
}
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
socket.volatile.to(roomId).emit("cursor-move", data); socket.volatile.to(roomId).emit("cursor-move", payload);
}); });
socket.on("element-update", (data) => { socket.on("element-update", (data) => {
+1 -2
View File
@@ -161,8 +161,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const previewHtml = svg.outerHTML; const previewHtml = svg.outerHTML;
setPreviewSvg(previewHtml); setPreviewSvg(previewHtml);
// Save to backend and notify parent // Keep this local to avoid dashboard mount storms writing previews.
api.updateDrawingPreview(drawing.id, previewHtml).catch(console.error);
onPreviewGenerated?.(drawing.id, previewHtml); onPreviewGenerated?.(drawing.id, previewHtml);
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
+224 -54
View File
@@ -33,6 +33,26 @@ interface Peer extends UserIdentity {
isActive: boolean; isActive: boolean;
} }
type Pointer = { x: number; y: number };
type RemoteCursorState = {
id: string;
username: string;
color: string;
button: string;
selectedElementIds: Record<string, boolean>;
pointer: Pointer;
startPointer: Pointer;
targetPointer: Pointer;
targetAt: number;
};
const CURSOR_FAST_EMIT_MS = 100;
const CURSOR_SLOW_EMIT_MS = 220;
const CURSOR_TINY_MOVE_PX = 2;
const CURSOR_INTERPOLATION_MS = 120;
const PREVIEW_IDLE_DEBOUNCE_MS = 25_000;
export const Editor: React.FC = () => { export const Editor: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -70,13 +90,15 @@ export const Editor: React.FC = () => {
const lastCursorEmit = useRef<number>(0); const lastCursorEmit = useRef<number>(0);
const lastPointerButtonRef = useRef<string>("up"); const lastPointerButtonRef = useRef<string>("up");
const lastPointerSelectionSigRef = useRef<string>("{}"); const lastPointerSelectionSigRef = useRef<string>("{}");
const lastPointerRef = useRef<{ x: number; y: number } | null>(null); const lastPointerRef = useRef<Pointer | null>(null);
const lastEmittedPointerRef = useRef<Pointer | null>(null);
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map()); const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
const isBootstrappingScene = useRef(true); const isBootstrappingScene = useRef(true);
const hasHydratedInitialScene = useRef(false); const hasHydratedInitialScene = useRef(false);
const isUnmounting = useRef(false); const isUnmounting = useRef(false);
const isSyncing = useRef(false); const isSyncing = useRef(false);
const cursorBuffer = useRef<Map<string, any>>(new Map()); const remoteCursorStatesRef = useRef<Map<string, RemoteCursorState>>(new Map());
const cursorRenderDirtyRef = useRef(false);
const animationFrameId = useRef<number>(0); const animationFrameId = useRef<number>(0);
const latestElementsRef = useRef<readonly any[]>([]); const latestElementsRef = useRef<readonly any[]>([]);
const initialSceneElementsRef = useRef<readonly any[]>([]); const initialSceneElementsRef = useRef<readonly any[]>([]);
@@ -92,6 +114,15 @@ export const Editor: React.FC = () => {
const pointerDownRef = useRef(false); const pointerDownRef = useRef(false);
const finalSyncTimeoutRef = useRef<number | null>(null); const finalSyncTimeoutRef = useRef<number | null>(null);
const serverSceneSeqRef = useRef(0); const serverSceneSeqRef = useRef(0);
const lastSavedPreviewRef = useRef<string | null>(null);
const previewDirtyRef = useRef(false);
const queuePreviewSaveRef = useRef<((params: {
drawingId: string;
elements: readonly any[];
appState: any;
files: Record<string, any>;
immediate?: boolean;
}) => void) | null>(null);
const MAX_PREVIEW_PAYLOAD_BYTES = 200_000; const MAX_PREVIEW_PAYLOAD_BYTES = 200_000;
const PREVIEW_WEBP_WIDTH = 480; const PREVIEW_WEBP_WIDTH = 480;
const PREVIEW_WEBP_QUALITY = 0.72; const PREVIEW_WEBP_QUALITY = 0.72;
@@ -268,6 +299,37 @@ export const Editor: React.FC = () => {
[] []
); );
const getPointerDistance = useCallback((a: Pointer | null, b: Pointer | null): number => {
if (!a || !b) return Number.POSITIVE_INFINITY;
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.hypot(dx, dy);
}, []);
const interpolatePointer = useCallback((start: Pointer, target: Pointer, progress: number): Pointer => {
if (progress >= 1) return target;
if (progress <= 0) return start;
return {
x: start.x + (target.x - start.x) * progress,
y: start.y + (target.y - start.y) * progress,
};
}, []);
const hasCollaboratorChanged = useCallback((prev: any, next: any): boolean => {
if (!prev) return true;
const prevPointer = prev.pointer;
const nextPointer = next.pointer;
if (!prevPointer || !nextPointer) return true;
if (Math.abs(prevPointer.x - nextPointer.x) > 0.1) return true;
if (Math.abs(prevPointer.y - nextPointer.y) > 0.1) return true;
if (prev.button !== next.button) return true;
if (prev.username !== next.username) return true;
if (prev.color?.background !== next.color?.background) return true;
const prevSelectionSig = JSON.stringify(Object.keys(prev.selectedElementIds || {}).sort());
const nextSelectionSig = JSON.stringify(Object.keys(next.selectedElementIds || {}).sort());
return prevSelectionSig !== nextSelectionSig;
}, []);
const emitFilesDeltaIfNeeded = useCallback( const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record<string, any>) => { (nextFiles: Record<string, any>) => {
if (!canEditScene) return false; if (!canEditScene) return false;
@@ -374,26 +436,36 @@ export const Editor: React.FC = () => {
touchedElementIdsRef.current.clear(); touchedElementIdsRef.current.clear();
}, [canEditScene, recordElementVersion, emitSceneOp]); }, [canEditScene, recordElementVersion, emitSceneOp]);
const emitCursorPresence = useCallback((button: string = "up") => { const emitCursorPacket = useCallback((pointer: Pointer, button: string) => {
if (!socketRef.current || !id) return; if (!socketRef.current || !id) return;
const selectedElementIds = excalidrawAPI.current?.getAppState?.().selectedElementIds || {}; const selectedElementIds = excalidrawAPI.current?.getAppState?.().selectedElementIds || {};
const selectionSig = JSON.stringify(Object.keys(selectedElementIds || {}).sort()); const selectionSig = JSON.stringify(Object.keys(selectedElementIds || {}).sort());
const selectionChanged = selectionSig !== lastPointerSelectionSigRef.current;
socketRef.current.emit("cursor-move", { const payload: Record<string, any> = {
pointer: lastPointerRef.current || { x: 0, y: 0 }, pointer,
button, button,
selectedElementIds,
username: me.name, username: me.name,
userId: me.id, userId: me.id,
drawingId: id, drawingId: id,
color: me.color, color: me.color,
}); };
if (selectionChanged) {
payload.selectedElementIds = selectedElementIds;
}
socketRef.current.emit("cursor-move", payload);
lastPointerButtonRef.current = button; lastPointerButtonRef.current = button;
lastPointerSelectionSigRef.current = selectionSig; lastPointerSelectionSigRef.current = selectionSig;
lastCursorEmit.current = Date.now(); lastCursorEmit.current = Date.now();
lastEmittedPointerRef.current = pointer;
}, [id, me]); }, [id, me]);
const emitCursorPresence = useCallback((button: string = "up") => {
emitCursorPacket(lastPointerRef.current || { x: 0, y: 0 }, button);
}, [emitCursorPacket]);
const queueFinalPointerSync = useCallback(() => { const queueFinalPointerSync = useCallback(() => {
emitFinalPointerSync(); emitFinalPointerSync();
if (finalSyncTimeoutRef.current !== null) { if (finalSyncTimeoutRef.current !== null) {
@@ -471,15 +543,43 @@ export const Editor: React.FC = () => {
// Start the render loop for cursors // Start the render loop for cursors
const renderLoop = () => { const renderLoop = () => {
if (cursorBuffer.current.size > 0 && excalidrawAPI.current) { if (excalidrawAPI.current) {
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
const now = performance.now();
let hasActiveInterpolation = false;
let shouldUpdateCollaborators = cursorRenderDirtyRef.current;
cursorBuffer.current.forEach((data, userId) => { remoteCursorStatesRef.current.forEach((state, userId) => {
collaborators.set(userId, data); const elapsed = now - state.targetAt;
const progress = Math.max(0, Math.min(1, elapsed / CURSOR_INTERPOLATION_MS));
const nextPointer = interpolatePointer(state.startPointer, state.targetPointer, progress);
if (progress < 1) {
hasActiveInterpolation = true;
}
if (getPointerDistance(state.pointer, nextPointer) > 0.1) {
state.pointer = nextPointer;
shouldUpdateCollaborators = true;
}
const collaborator = {
pointer: state.pointer,
button: state.button || 'up',
selectedElementIds: state.selectedElementIds || {},
username: state.username,
color: { background: state.color, stroke: state.color },
id: state.id,
};
const previousCollaborator = collaborators.get(userId);
if (hasCollaboratorChanged(previousCollaborator, collaborator)) {
collaborators.set(userId, collaborator);
shouldUpdateCollaborators = true;
}
}); });
cursorBuffer.current.clear(); if (shouldUpdateCollaborators) {
excalidrawAPI.current.updateScene({ collaborators }); excalidrawAPI.current.updateScene({ collaborators });
}
cursorRenderDirtyRef.current = hasActiveInterpolation;
} }
animationFrameId.current = requestAnimationFrame(renderLoop); animationFrameId.current = requestAnimationFrame(renderLoop);
}; };
@@ -490,24 +590,49 @@ export const Editor: React.FC = () => {
if (excalidrawAPI.current) { if (excalidrawAPI.current) {
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
const activeUserIds = new Set(users.filter((u) => u.isActive).map((u) => u.id));
users.forEach(user => { users.forEach(user => {
if (!user.isActive && user.id !== me.id) { if (!user.isActive && user.id !== me.id) {
collaborators.delete(user.id); collaborators.delete(user.id);
remoteCursorStatesRef.current.delete(user.id);
} }
}); });
for (const userId of Array.from(remoteCursorStatesRef.current.keys())) {
if (!activeUserIds.has(userId)) {
remoteCursorStatesRef.current.delete(userId);
collaborators.delete(userId);
}
}
cursorRenderDirtyRef.current = true;
excalidrawAPI.current.updateScene({ collaborators }); excalidrawAPI.current.updateScene({ collaborators });
} }
}); });
socket.on('cursor-move', (data: any) => { socket.on('cursor-move', (data: any) => {
cursorBuffer.current.set(data.userId, { if (!data || typeof data.userId !== "string") return;
pointer: data.pointer, if (!data.pointer || typeof data.pointer.x !== "number" || typeof data.pointer.y !== "number") return;
button: data.button || 'up', const pointer: Pointer = { x: data.pointer.x, y: data.pointer.y };
selectedElementIds: data.selectedElementIds || {}, const existing = remoteCursorStatesRef.current.get(data.userId);
username: data.username, const selectionFromPayload =
color: { background: data.color, stroke: data.color }, data.selectedElementIds && typeof data.selectedElementIds === "object"
? data.selectedElementIds
: existing?.selectedElementIds || {};
const button = typeof data.button === "string" ? data.button : existing?.button || "up";
const username = typeof data.username === "string" ? data.username : existing?.username || "User";
const color = typeof data.color === "string" ? data.color : existing?.color || "#4f46e5";
remoteCursorStatesRef.current.set(data.userId, {
id: data.userId, id: data.userId,
username,
color,
button,
selectedElementIds: selectionFromPayload,
pointer: existing?.pointer || pointer,
startPointer: existing?.pointer || pointer,
targetPointer: pointer,
targetAt: performance.now(),
}); });
cursorRenderDirtyRef.current = true;
}); });
const applyRemoteOps = (ops: any[]) => { const applyRemoteOps = (ops: any[]) => {
@@ -626,13 +751,19 @@ export const Editor: React.FC = () => {
socket.off('element-update'); socket.off('element-update');
socket.disconnect(); socket.disconnect();
cancelAnimationFrame(animationFrameId.current); cancelAnimationFrame(animationFrameId.current);
remoteCursorStatesRef.current.clear();
cursorRenderDirtyRef.current = false;
}; };
}, [id, me, isReady, recordElementVersion]); }, [id, me, isReady, recordElementVersion, getPointerDistance, hasCollaboratorChanged, interpolatePointer]);
const onPointerUpdate = useCallback((payload: any) => { const onPointerUpdate = useCallback((payload: any) => {
const now = Date.now(); const now = Date.now();
if (payload?.pointer && typeof payload.pointer.x === "number" && typeof payload.pointer.y === "number") { const hasPointer = payload?.pointer && typeof payload.pointer.x === "number" && typeof payload.pointer.y === "number";
lastPointerRef.current = payload.pointer; const pointer: Pointer = hasPointer
? payload.pointer
: (lastPointerRef.current || { x: 0, y: 0 });
if (hasPointer) {
lastPointerRef.current = pointer;
} }
const button = typeof payload?.button === "string" ? payload.button : "up"; const button = typeof payload?.button === "string" ? payload.button : "up";
const selectedElementIdsFromPayload = const selectedElementIdsFromPayload =
@@ -646,20 +777,17 @@ export const Editor: React.FC = () => {
const forceEmit = const forceEmit =
button !== lastPointerButtonRef.current || button !== lastPointerButtonRef.current ||
selectionSig !== lastPointerSelectionSigRef.current; selectionSig !== lastPointerSelectionSigRef.current;
const pointerMovedDistance = getPointerDistance(pointer, lastEmittedPointerRef.current);
const pointerMoved = pointerMovedDistance >= 0.1;
if (!pointerMoved && !forceEmit) {
return;
}
const emitIntervalMs = pointerMovedDistance <= CURSOR_TINY_MOVE_PX
? CURSOR_SLOW_EMIT_MS
: CURSOR_FAST_EMIT_MS;
if ((now - lastCursorEmit.current > 50 || forceEmit) && socketRef.current) { if (socketRef.current && (now - lastCursorEmit.current > emitIntervalMs || forceEmit)) {
socketRef.current.emit('cursor-move', { emitCursorPacket(pointer, button);
pointer: payload.pointer,
button,
selectedElementIds,
username: me.name,
userId: me.id,
drawingId: id,
color: me.color
});
lastCursorEmit.current = now;
lastPointerButtonRef.current = button;
lastPointerSelectionSigRef.current = selectionSig;
} }
const isPointerDown = button && button !== "up"; const isPointerDown = button && button !== "up";
if (isPointerDown) { if (isPointerDown) {
@@ -671,7 +799,7 @@ export const Editor: React.FC = () => {
emitCursorPresence("up"); emitCursorPresence("up");
}); });
} }
}, [id, me, queueFinalPointerSync, emitCursorPresence]); }, [queueFinalPointerSync, emitCursorPresence, emitCursorPacket, getPointerDistance]);
// Refs for API interaction // Refs for API interaction
const excalidrawAPI = useRef<any>(null); const excalidrawAPI = useRef<any>(null);
@@ -704,14 +832,12 @@ export const Editor: React.FC = () => {
// Keep preview in sync after async file data becomes available. // Keep preview in sync after async file data becomes available.
if (didEmit && id && latestAppStateRef.current) { if (didEmit && id && latestAppStateRef.current) {
hasSceneChangesSinceLoadRef.current = true; hasSceneChangesSinceLoadRef.current = true;
if (savePreviewRef.current) { queuePreviewSaveRef.current?.({
void savePreviewRef.current( drawingId: id,
id, elements: latestElementsRef.current,
latestElementsRef.current, appState: latestAppStateRef.current,
latestAppStateRef.current, files: latestFilesRef.current || {},
latestFilesRef.current || {} });
);
}
} }
}; };
} }
@@ -890,10 +1016,18 @@ export const Editor: React.FC = () => {
elementCount: normalizedSnapshot.length, elementCount: normalizedSnapshot.length,
}); });
if (lastSavedPreviewRef.current === preview) {
previewDirtyRef.current = false;
return;
}
await api.updateDrawingPreview(drawingId, preview); await api.updateDrawingPreview(drawingId, preview);
lastSavedPreviewRef.current = preview;
previewDirtyRef.current = false;
console.log("[Editor] Preview save complete", { drawingId }); console.log("[Editor] Preview save complete", { drawingId });
} catch (err) { } catch (err) {
previewDirtyRef.current = true;
console.error('Failed to save preview', err); console.error('Failed to save preview', err);
} }
}; };
@@ -915,10 +1049,29 @@ export const Editor: React.FC = () => {
if (savePreviewRef.current) { if (savePreviewRef.current) {
savePreviewRef.current(drawingId, elements, appState, files); savePreviewRef.current(drawingId, elements, appState, files);
} }
}, 10000), }, PREVIEW_IDLE_DEBOUNCE_MS),
[] []
); );
queuePreviewSaveRef.current = ({
drawingId,
elements,
appState,
files,
immediate = false,
}) => {
if (!drawingId || !appState) return;
previewDirtyRef.current = true;
if (immediate) {
debouncedSavePreview.cancel();
if (savePreviewRef.current) {
void savePreviewRef.current(drawingId, elements, appState, files);
}
return;
}
debouncedSavePreview(drawingId, elements, appState, files);
};
const debouncedSaveLibrary = useCallback( const debouncedSaveLibrary = useCallback(
debounce((items: any[]) => { debounce((items: any[]) => {
if (saveLibraryRef.current) { if (saveLibraryRef.current) {
@@ -939,6 +1092,14 @@ export const Editor: React.FC = () => {
emitFinalPointerSync(); emitFinalPointerSync();
debouncedSavePreview.flush(); debouncedSavePreview.flush();
if (previewDirtyRef.current && savePreviewRef.current && latestAppStateRef.current) {
void savePreviewRef.current(
id,
latestElementsRef.current,
latestAppStateRef.current,
latestFilesRef.current || {}
);
}
if (socketRef.current) { if (socketRef.current) {
socketRef.current.emit("scene-flush", { drawingId: id }); socketRef.current.emit("scene-flush", { drawingId: id });
} }
@@ -1045,9 +1206,14 @@ export const Editor: React.FC = () => {
finalSyncTimeoutRef.current = null; finalSyncTimeoutRef.current = null;
} }
serverSceneSeqRef.current = 0; serverSceneSeqRef.current = 0;
remoteCursorStatesRef.current.clear();
cursorRenderDirtyRef.current = false;
lastPointerButtonRef.current = "up"; lastPointerButtonRef.current = "up";
lastPointerSelectionSigRef.current = "{}"; lastPointerSelectionSigRef.current = "{}";
lastPointerRef.current = null; lastPointerRef.current = null;
lastEmittedPointerRef.current = null;
previewDirtyRef.current = false;
lastSavedPreviewRef.current = null;
excalidrawAPI.current = null; excalidrawAPI.current = null;
setIsReady(false); setIsReady(false);
setIsSceneLoading(true); setIsSceneLoading(true);
@@ -1086,6 +1252,7 @@ export const Editor: React.FC = () => {
const elements = data.elements || []; const elements = data.elements || [];
const files = data.files || {}; const files = data.files || {};
const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0; const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0;
lastSavedPreviewRef.current = hasPreview ? data.preview!.trim() : null;
const loadedRenderable = hasRenderableElements(elements); const loadedRenderable = hasRenderableElements(elements);
suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview; suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview;
hasSceneChangesSinceLoadRef.current = false; hasSceneChangesSinceLoadRef.current = false;
@@ -1311,9 +1478,14 @@ export const Editor: React.FC = () => {
fileCount: Object.keys(filesSnapshot).length, fileCount: Object.keys(filesSnapshot).length,
}); });
if (id) { if (id) {
debouncedSavePreview(id, allElements, appState, filesSnapshot); queuePreviewSaveRef.current?.({
drawingId: id,
elements: allElements,
appState,
files: filesSnapshot,
});
} }
}, [debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot, canEditScene]); }, [broadcastChanges, id, resolveSafeSnapshot, canEditScene]);
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
// are still broadcast to collaborators AND persisted to the server. // are still broadcast to collaborators AND persisted to the server.
@@ -1332,14 +1504,12 @@ export const Editor: React.FC = () => {
// Keep preview updated after async image data becomes available. // Keep preview updated after async image data becomes available.
if (didEmit && latestAppStateRef.current) { if (didEmit && latestAppStateRef.current) {
hasSceneChangesSinceLoadRef.current = true; hasSceneChangesSinceLoadRef.current = true;
if (savePreviewRef.current) { queuePreviewSaveRef.current?.({
void savePreviewRef.current( drawingId: id,
id, elements: latestElementsRef.current,
latestElementsRef.current, appState: latestAppStateRef.current,
latestAppStateRef.current, files: nextFiles,
nextFiles });
);
}
} }
}, 1000); }, 1000);