perf(collab): reduce cursor and preview update churn
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+223
-53
@@ -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,16 +543,44 @@ 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);
|
||||||
};
|
};
|
||||||
renderLoop();
|
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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user