Skip to content
File

Blob: src/client/components/canvas/excalidraw-binding.ts

typescript545 lines
1import * as Y from "yjs";
2import type { Awareness } from "y-protocols/awareness";
3import { CaptureUpdateAction, reconcileElements } from "@excalidraw/excalidraw";
4import type { ExcalidrawElement, FileId, OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
5import type {
6 AppState,
7 BinaryFileData,
8 BinaryFiles,
9 Collaborator,
10 CollaboratorPointer,
11 DataURL,
12 ExcalidrawImperativeAPI,
13 SocketId,
14} from "@excalidraw/excalidraw/types";
15import { fetchUploadAsDataURL, uploadFile } from "@/client/lib/uploads";
16import { toast } from "@/client/components/toast-store";
17import type { ResolveIdentity } from "@/client/lib/presence-identity";
18import { friendlyName } from "@/client/lib/friendly-name";
19import { localWinsVersionTiebreak } from "./tiebreak";
20 
21const APP_STATE_KEYS = [
22 "viewBackgroundColor",
23 "currentItemStrokeColor",
24 "currentItemBackgroundColor",
25 "currentItemFillStyle",
26 "currentItemStrokeWidth",
27 "currentItemStrokeStyle",
28 "currentItemRoughness",
29 "currentItemOpacity",
30 "currentItemFontFamily",
31 "currentItemFontSize",
32 "currentItemTextAlign",
33 "currentItemStartArrowhead",
34 "currentItemEndArrowhead",
35 "gridSize",
36 "gridModeEnabled",
37] as const;
38 
39type PersistedAppStateKey = (typeof APP_STATE_KEYS)[number];
40 
41/** MIME types Excalidraw renders natively on canvas. The upload route's
42 * ALLOWED_UPLOAD_TYPES is a superset (includes application/pdf + image/heic)
43 * that doesn't render here; filter at the client so the binding only
44 * persists renderable assets. */
45const CANVAS_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
46 
47const POINTER_UPDATE_INTERVAL_MS = 50;
48 
49export interface ExcalidrawBindingDeps {
50 workspaceId: string | undefined;
51 shareToken: string | undefined;
52 pageId: string;
53 canInsertImages: boolean;
54 userId: string | null;
55 resolveIdentity: ResolveIdentity;
56}
57 
58export interface ExcalidrawBindingOpts {
59 onAppStateFromRemote: (partial: Partial<AppState>) => void;
60 onCollaboratorsChange: (collaborators: Map<SocketId, Collaborator>) => void;
61 /**
62 * Deps are read via a getter so the owning component can update them
63 * (e.g. on member list change or online/offline flip) without destroying
64 * the binding. A destroy+recreate would tear down awareness and re-fetch
65 * every image from R2.
66 */
67 getDeps: () => ExcalidrawBindingDeps;
68}
69 
70type ElementMap = Y.Map<unknown>;
71 
72function cloneElement(element: OrderedExcalidrawElement): OrderedExcalidrawElement {
73 return structuredClone(element);
74}
75 
76function extractUploadId(url: string): string {
77 // /uploads/{id} or /uploads/{id}?share=...
78 const match = url.match(/\/uploads\/([^/?]+)/);
79 if (!match) throw new Error(`Cannot parse upload id from url: ${url}`);
80 return match[1];
81}
82 
83function mimeToExt(mime: string): string {
84 switch (mime) {
85 case "image/png":
86 return "png";
87 case "image/jpeg":
88 return "jpg";
89 case "image/gif":
90 return "gif";
91 case "image/webp":
92 return "webp";
93 default:
94 return "bin";
95 }
96}
97 
98function dataURLToBytes(dataURL: string): Uint8Array {
99 const base64 = dataURL.replace(/^data:[^,]+,/, "");
100 const binary = atob(base64);
101 const bytes = new Uint8Array(binary.length);
102 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
103 return bytes;
104}
105 
106/** Excalidraw's cursor renderer ignores Collaborator.color and derives the
107 * hue from `hashToInteger(collaborator.id ?? socketId) % 37 * 10` at fixed
108 * S=100%, L=83%. We set `id` to a single char whose charCode lands on one of
109 * six pastel hues chosen to read well against the dark canvas (zinc-950) and
110 * stay clear of brand amethyst (~hue 280) and pure yellow (~hue 60).
111 *
112 * J (74) → hue 0 (pale coral)
113 * M (77) → hue 30 (pale peach)
114 * S (83) → hue 90 (pale lime)
115 * 4 (52) → hue 150 (pale mint)
116 * 7 (55) → hue 180 (pale aqua)
117 * E (69) → hue 320 (pale rose)
118 */
119const CURSOR_ID_BUCKETS = ["J", "M", "S", "4", "7", "E"] as const;
120 
121function cursorIdBucket(userId: string | null, clientId: number): string {
122 const seed = userId ?? String(clientId);
123 let hash = 0;
124 for (let i = 0; i < seed.length; i++) {
125 hash = (hash * 31 + seed.charCodeAt(i)) | 0;
126 }
127 return CURSOR_ID_BUCKETS[Math.abs(hash) % CURSOR_ID_BUCKETS.length];
128}
129 
130export class ExcalidrawBinding {
131 private readonly api: ExcalidrawImperativeAPI;
132 private readonly ydoc: Y.Doc;
133 private readonly yElements: Y.Map<ElementMap>;
134 private readonly yAppState: Y.Map<unknown>;
135 private readonly yFileRefs: Y.Map<string>;
136 private readonly awareness: Awareness;
137 private readonly opts: ExcalidrawBindingOpts;
138 private readonly lastAppliedVersion = new Map<string, number>();
139 private readonly pendingUploads = new Set<string>();
140 private readonly hydratedFiles = new Set<string>();
141 private readonly elementsObserver: (events: Y.YEvent<ElementMap>[], tx: Y.Transaction) => void;
142 private readonly appStateObserver: (event: Y.YMapEvent<unknown>, tx: Y.Transaction) => void;
143 private readonly fileRefsObserver: (event: Y.YMapEvent<string>, tx: Y.Transaction) => void;
144 private readonly awarenessObserver: () => void;
145 private lastSelectedIdsKey: string | null = null;
146 private lastPointerAt = 0;
147 private dirty = false;
148 private rafHandle: number | null = null;
149 private appStateDebounce: number | null = null;
150 private destroyed = false;
151 private fileHydrationErrorShown = false;
152 
153 constructor(
154 api: ExcalidrawImperativeAPI,
155 ydoc: Y.Doc,
156 yElements: Y.Map<ElementMap>,
157 yAppState: Y.Map<unknown>,
158 yFileRefs: Y.Map<string>,
159 awareness: Awareness,
160 opts: ExcalidrawBindingOpts,
161 ) {
162 this.api = api;
163 this.ydoc = ydoc;
164 this.yElements = yElements;
165 this.yAppState = yAppState;
166 this.yFileRefs = yFileRefs;
167 this.awareness = awareness;
168 this.opts = opts;
169 
170 this.elementsObserver = (_events, tx) => {
171 if (tx.origin === this) return;
172 this.scheduleRebuild();
173 };
174 this.appStateObserver = (_event, tx) => {
175 if (tx.origin === this) return;
176 this.applyRemoteAppState();
177 };
178 this.fileRefsObserver = (event, tx) => {
179 // Our own writes land here too; only react to new additions regardless
180 // of origin so local uploads that just resolved get hydrated into the
181 // scene for element rendering.
182 for (const [fileId, change] of event.changes.keys) {
183 if (change.action !== "add") continue;
184 const uploadId = this.yFileRefs.get(fileId);
185 if (uploadId) void this.hydrateFile(fileId, uploadId);
186 }
187 // Suppress the tx lint — observer doesn't need it but signature matches
188 void tx;
189 };
190 this.awarenessObserver = () => {
191 this.opts.onCollaboratorsChange(this.buildCollaborators());
192 };
193 
194 yElements.observeDeep(this.elementsObserver);
195 yAppState.observe(this.appStateObserver);
196 yFileRefs.observe(this.fileRefsObserver);
197 awareness.on("change", this.awarenessObserver);
198 
199 // Publish local user identity (anonymous for share viewers) and
200 // notify the host of the current collaborator set right away so it
201 // renders peers that were already present when this binding mounted.
202 awareness.setLocalStateField("user", { userId: opts.getDeps().userId });
203 this.opts.onCollaboratorsChange(this.buildCollaborators());
204 
205 // Seed Excalidraw with any content already in the yElements / yAppState
206 // roots at construction time (IDB cache or first WS sync that already
207 // landed before the binding mounted).
208 this.applyRemoteAppState();
209 this.applyRemoteElements();
210 void this.hydrateAllFiles();
211 }
212 
213 /** Fired from Excalidraw's onChange. Writes local wins into yElements,
214 * debounces app state, and uploads any new pending image elements. */
215 handleChange(elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles): void {
216 if (this.destroyed) return;
217 this.writeLocalElements(elements);
218 this.scheduleAppStateWrite(appState);
219 this.handleImageUploads(elements, files);
220 this.syncSelectedElementIds(appState);
221 }
222 
223 /** Fired from Excalidraw's onPointerUpdate — throttle to ~20Hz. */
224 handlePointerUpdate(payload: {
225 pointer: { x: number; y: number };
226 button: "up" | "down";
227 pointersMap: Map<number, unknown>;
228 }): void {
229 if (this.destroyed) return;
230 // Skip phantom multi-touch / stylus events where a real pointer isn't the source
231 if (payload.pointersMap.size > 1) return;
232 const now = Date.now();
233 if (now - this.lastPointerAt < POINTER_UPDATE_INTERVAL_MS) return;
234 this.lastPointerAt = now;
235 const pointer: CollaboratorPointer = {
236 x: payload.pointer.x,
237 y: payload.pointer.y,
238 tool: "pointer",
239 };
240 this.awareness.setLocalStateField("pointer", pointer);
241 this.awareness.setLocalStateField("button", payload.button);
242 }
243 
244 destroy(): void {
245 this.destroyed = true;
246 this.yElements.unobserveDeep(this.elementsObserver);
247 this.yAppState.unobserve(this.appStateObserver);
248 this.yFileRefs.unobserve(this.fileRefsObserver);
249 this.awareness.off("change", this.awarenessObserver);
250 // Clear our awareness slot so peers see us disconnect promptly
251 this.awareness.setLocalState(null);
252 if (this.rafHandle !== null) cancelAnimationFrame(this.rafHandle);
253 if (this.appStateDebounce !== null) window.clearTimeout(this.appStateDebounce);
254 }
255 
256 /**
257 * Publishes a new userId to awareness without tearing down observers.
258 * The owning pane calls this when the local auth user changes — e.g.
259 * sign-in during an open session. `getDeps` also exposes this value for
260 * peers resolving collaborators on each awareness event, so they
261 * converge independently.
262 */
263 setUserId(userId: string | null): void {
264 if (this.destroyed) return;
265 this.awareness.setLocalStateField("user", { userId });
266 }
267 
268 private writeLocalElements(elements: readonly OrderedExcalidrawElement[]): void {
269 if (elements.length === 0 && this.yElements.size === 0) return;
270 
271 this.ydoc.transact(() => {
272 for (const el of elements) {
273 const existing = this.yElements.get(el.id);
274 const remote = existing ? (existing.get("element") as ExcalidrawElement | undefined) : undefined;
275 if (!localWinsVersionTiebreak(el, remote ?? null)) continue;
276 
277 if (existing) {
278 existing.set("element", cloneElement(el));
279 } else {
280 const entry = new Y.Map<unknown>();
281 this.yElements.set(el.id, entry);
282 entry.set("element", cloneElement(el));
283 }
284 }
285 }, this);
286 }
287 
288 private scheduleRebuild(): void {
289 if (this.dirty || this.destroyed) return;
290 this.dirty = true;
291 this.rafHandle = requestAnimationFrame(() => {
292 this.dirty = false;
293 this.rafHandle = null;
294 if (!this.destroyed) this.applyRemoteElements();
295 });
296 }
297 
298 private collectRemoteElements(): OrderedExcalidrawElement[] {
299 const out: OrderedExcalidrawElement[] = [];
300 this.yElements.forEach((entry) => {
301 const el = entry.get("element") as OrderedExcalidrawElement | undefined;
302 if (el) out.push(cloneElement(el));
303 });
304 // Excalidraw's reconciler assumes fractional-index order; sort so late-
305 // arriving inserts land at the correct z-position.
306 out.sort((a, b) => {
307 const ai = a.index ?? "";
308 const bi = b.index ?? "";
309 if (ai === bi) return 0;
310 return ai < bi ? -1 : 1;
311 });
312 return out;
313 }
314 
315 private applyRemoteElements(): void {
316 const remote = this.collectRemoteElements();
317 
318 // Skip rebuilds where no element version has advanced since the last apply.
319 // Covers tombstone-only and awareness-adjacent churn without running
320 // reconcileElements + updateScene.
321 let anyAdvanced = remote.length !== this.lastAppliedVersion.size;
322 if (!anyAdvanced) {
323 for (const el of remote) {
324 const prior = this.lastAppliedVersion.get(el.id);
325 if (prior === undefined || el.version > prior) {
326 anyAdvanced = true;
327 break;
328 }
329 }
330 }
331 if (!anyAdvanced) return;
332 
333 const local = this.api.getSceneElementsIncludingDeleted() as readonly OrderedExcalidrawElement[];
334 const appState = this.api.getAppState();
335 const reconciled = reconcileElements(local, remote as unknown as Parameters<typeof reconcileElements>[1], appState);
336 
337 this.lastAppliedVersion.clear();
338 for (const el of reconciled) {
339 this.lastAppliedVersion.set(el.id, el.version);
340 }
341 
342 this.api.updateScene({
343 elements: reconciled,
344 captureUpdate: CaptureUpdateAction.NEVER,
345 });
346 }
347 
348 private scheduleAppStateWrite(appState: AppState): void {
349 if (this.appStateDebounce !== null) window.clearTimeout(this.appStateDebounce);
350 this.appStateDebounce = window.setTimeout(() => {
351 this.appStateDebounce = null;
352 if (this.destroyed) return;
353 this.writeAppState(appState);
354 }, 250);
355 }
356 
357 private writeAppState(appState: AppState): void {
358 this.ydoc.transact(() => {
359 for (const key of APP_STATE_KEYS) {
360 const value = appState[key as keyof AppState];
361 if (value === undefined) continue;
362 if (this.yAppState.get(key) !== value) {
363 this.yAppState.set(key, value);
364 }
365 }
366 }, this);
367 }
368 
369 private applyRemoteAppState(): void {
370 const partial: Partial<AppState> = {};
371 let changed = false;
372 for (const key of APP_STATE_KEYS) {
373 if (!this.yAppState.has(key)) continue;
374 (partial as Record<PersistedAppStateKey, unknown>)[key] = this.yAppState.get(key);
375 changed = true;
376 }
377 if (changed) this.opts.onAppStateFromRemote(partial);
378 }
379 
380 // ------------------------------------------------------------------
381 // Image upload + hydration
382 // ------------------------------------------------------------------
383 
384 private handleImageUploads(elements: readonly OrderedExcalidrawElement[], files: BinaryFiles): void {
385 const { workspaceId, canInsertImages } = this.opts.getDeps();
386 if (!canInsertImages || !workspaceId) return;
387 
388 for (const el of elements) {
389 if (el.type !== "image") continue;
390 if (el.isDeleted) continue;
391 // `status: "pending"` means Excalidraw has the dataURL in `files` but
392 // we haven't persisted it yet.
393 const status = (el as unknown as { status?: string }).status;
394 const fileId = (el as unknown as { fileId?: string }).fileId;
395 if (status !== "pending" || !fileId) continue;
396 
397 // Three-way guard: don't double-upload.
398 if (this.yFileRefs.has(fileId)) continue; // another peer (or us earlier) already persisted
399 if (this.pendingUploads.has(fileId)) continue; // this tab has an upload in flight
400 const fileData = files[fileId];
401 if (!fileData) continue; // bytes aren't materialised yet — wait for next onChange
402 
403 void this.uploadImage(fileId, fileData);
404 }
405 }
406 
407 private async uploadImage(fileId: string, fileData: BinaryFileData): Promise<void> {
408 const { workspaceId, pageId, shareToken } = this.opts.getDeps();
409 if (!workspaceId) return;
410 
411 this.pendingUploads.add(fileId);
412 try {
413 if (!CANVAS_IMAGE_MIMES.has(fileData.mimeType)) {
414 toast.error("Only PNG, JPG, GIF, or WebP images are supported on canvas pages");
415 this.markImageStatus(fileId, "error");
416 return;
417 }
418 
419 const bytes = dataURLToBytes(fileData.dataURL);
420 const ext = mimeToExt(fileData.mimeType);
421 // Cast to satisfy TS lib.dom's BlobPart bound — the underlying
422 // buffer is always a real ArrayBuffer, never SharedArrayBuffer.
423 const file = new File([bytes as BlobPart], `${fileId}.${ext}`, { type: fileData.mimeType });
424 const url = await uploadFile(workspaceId, file, pageId, shareToken);
425 const uploadId = extractUploadId(url);
426 
427 // Only durably record the mapping if no peer has already won the race.
428 // The dropped blob on this side becomes R2 garbage that the deferred
429 // upload-GC will sweep (see CLAUDE.md).
430 if (!this.yFileRefs.has(fileId)) {
431 this.ydoc.transact(() => {
432 this.yFileRefs.set(fileId, uploadId);
433 }, this);
434 }
435 
436 this.markImageStatus(fileId, "saved");
437 // Mark as hydrated — the dataURL we've been holding locally IS the
438 // content, no need to re-fetch from the upload URL.
439 this.hydratedFiles.add(fileId);
440 } catch (err) {
441 // Network / 4xx / bad bytes — leave the element as error so the user
442 // can see the placeholder and optionally replace.
443 this.markImageStatus(fileId, "error");
444 void err;
445 } finally {
446 this.pendingUploads.delete(fileId);
447 }
448 }
449 
450 private markImageStatus(fileId: string, status: "saved" | "error"): void {
451 const scene = this.api.getSceneElementsIncludingDeleted();
452 let changed = false;
453 const next = scene.map((el) => {
454 const elFileId = (el as unknown as { fileId?: string }).fileId;
455 if (elFileId !== fileId) return el;
456 changed = true;
457 return { ...el, status } as typeof el;
458 });
459 if (!changed) return;
460 this.api.updateScene({
461 elements: next,
462 captureUpdate: CaptureUpdateAction.NEVER,
463 });
464 }
465 
466 private async hydrateAllFiles(): Promise<void> {
467 const entries = Array.from(this.yFileRefs.entries());
468 // Chunk in groups of 5 so very image-heavy canvases don't stall the
469 // main thread in one burst. FileReader + addFiles are both cheap but
470 // the network fetches benefit from a cap.
471 const BATCH = 5;
472 for (let i = 0; i < entries.length; i += BATCH) {
473 if (this.destroyed) return;
474 const batch = entries.slice(i, i + BATCH);
475 await Promise.all(batch.map(([fileId, uploadId]) => this.hydrateFile(fileId, uploadId)));
476 }
477 }
478 
479 private async hydrateFile(fileId: string, uploadId: string): Promise<void> {
480 if (this.destroyed) return;
481 if (this.hydratedFiles.has(fileId)) return;
482 try {
483 const { dataURL, mime } = await fetchUploadAsDataURL(uploadId, this.opts.getDeps().shareToken);
484 if (this.destroyed) return;
485 const binary: BinaryFileData = {
486 id: fileId as FileId,
487 dataURL: dataURL as DataURL,
488 mimeType: mime as BinaryFileData["mimeType"],
489 created: Date.now(),
490 };
491 this.api.addFiles([binary]);
492 this.hydratedFiles.add(fileId);
493 } catch (err) {
494 if (!this.fileHydrationErrorShown) {
495 this.fileHydrationErrorShown = true;
496 toast.error("Some canvas images couldn't be loaded");
497 }
498 void err;
499 }
500 }
501 
502 // ------------------------------------------------------------------
503 // Awareness: publish local, build collaborator map for Excalidraw prop
504 // ------------------------------------------------------------------
505 
506 private syncSelectedElementIds(appState: AppState): void {
507 const ids = appState.selectedElementIds;
508 const key = Object.keys(ids ?? {})
509 .filter((id) => ids[id])
510 .sort()
511 .join(",");
512 if (key === this.lastSelectedIdsKey) return;
513 this.lastSelectedIdsKey = key;
514 this.awareness.setLocalStateField("selectedElementIds", ids);
515 }
516 
517 private buildCollaborators(): Map<SocketId, Collaborator> {
518 const { resolveIdentity } = this.opts.getDeps();
519 const collaborators = new Map<SocketId, Collaborator>();
520 for (const [clientId, state] of this.awareness.getStates()) {
521 if (clientId === this.awareness.clientID) continue;
522 const typed = state as {
523 user?: { userId: string | null };
524 pointer?: CollaboratorPointer;
525 button?: "up" | "down";
526 selectedElementIds?: AppState["selectedElementIds"];
527 };
528 const userId = typed.user?.userId ?? null;
529 const identity = resolveIdentity(userId, clientId);
530 const socketId = String(clientId) as SocketId;
531 collaborators.set(socketId, {
532 // See CURSOR_ID_BUCKETS — the single char here steers the cursor hue.
533 id: cursorIdBucket(userId, clientId),
534 socketId,
535 pointer: typed.pointer,
536 button: typed.button,
537 selectedElementIds: typed.selectedElementIds,
538 username: identity.name || friendlyName(String(clientId)),
539 avatarUrl: identity.avatar_url ?? undefined,
540 });
541 }
542 return collaborators;
543 }
544}