@q9labsai/chalk-whiteboard 4.1.16 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Q9Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
20
+ OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,5 +1,5 @@
1
1
  // src/collab/engine.ts
2
- import { CaptureUpdateAction as CaptureUpdateAction2, hashElementsVersion, reconcileElements, restoreElements } from "@excalidraw/excalidraw";
2
+ import { CaptureUpdateAction as CaptureUpdateAction2, reconcileElements, restoreElements } from "@excalidraw/excalidraw";
3
3
 
4
4
  // src/collab/camera.ts
5
5
  function getWhiteboardCamera(appState) {
@@ -18,23 +18,12 @@ function withLocalWhiteboardCamera(sharedAppState, localAppState) {
18
18
 
19
19
  // src/collab/files.ts
20
20
  import { CaptureUpdateAction, MIME_TYPES, newElementWith } from "@excalidraw/excalidraw";
21
- import { sha256 } from "@noble/hashes/sha2.js";
22
- import { bytesToHex } from "@noble/hashes/utils.js";
23
- var dataURLToBlob = (dataURL) => {
24
- const [header, base64] = dataURL.split(",", 2);
25
- if (!header || !base64) throw new Error("invalid dataURL");
26
- const match = /data:(.+?)(;base64)?$/i.exec(header);
27
- const mimeType = match?.[1] ?? "application/octet-stream";
28
- const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
29
- return new Blob([bytes], { type: mimeType });
30
- };
31
21
  var blobToDataURL = (blob) => new Promise((resolve, reject) => {
32
22
  const reader = new FileReader();
33
23
  reader.onerror = () => reject(new Error("failed to read blob"));
34
24
  reader.onload = () => resolve(String(reader.result));
35
25
  reader.readAsDataURL(blob);
36
26
  });
37
- var sha256Hex = async (blob) => bytesToHex(sha256(new Uint8Array(await blob.arrayBuffer())));
38
27
  var isImageElement = (el) => el.type === "image";
39
28
  var toFileId = (value) => value;
40
29
  var toDataURL = (value) => {
@@ -68,29 +57,11 @@ var WhiteboardFilesSync = class {
68
57
  this.opts = opts;
69
58
  }
70
59
  opts;
71
- uploadTimer = null;
72
60
  downloadTimer = null;
73
- uploadQueue = [];
74
61
  downloadQueue = [];
75
- uploading = /* @__PURE__ */ new Set();
76
62
  downloading = /* @__PURE__ */ new Set();
77
63
  remotePendingUploads = 0;
78
64
  lastErrorAtMs = null;
79
- handleLocalScene(elementsAll, files) {
80
- for (const el of elementsAll) {
81
- if (!isImageElement(el) || el.isDeleted) continue;
82
- if (el.status !== "pending") continue;
83
- if (!el.fileId) continue;
84
- const fileId = String(el.fileId);
85
- if (this.uploading.has(fileId)) continue;
86
- if (this.uploadQueue.includes(fileId)) continue;
87
- const file = files[fileId];
88
- if (!file?.dataURL || !file?.mimeType) continue;
89
- this.uploadQueue.push(fileId);
90
- }
91
- this.emitState();
92
- if (this.uploadQueue.length > 0) this.scheduleUpload();
93
- }
94
65
  handleRemoteScene(elementsAll) {
95
66
  const haveFiles = this.opts.excalidrawAPI.getFiles();
96
67
  let pendingRemoteUploads = 0;
@@ -113,25 +84,14 @@ var WhiteboardFilesSync = class {
113
84
  if (this.downloadQueue.length > 0) this.scheduleDownload();
114
85
  }
115
86
  dispose() {
116
- if (this.uploadTimer) clearTimeout(this.uploadTimer);
117
87
  if (this.downloadTimer) clearTimeout(this.downloadTimer);
118
- this.uploadTimer = null;
119
88
  this.downloadTimer = null;
120
- this.uploadQueue = [];
121
89
  this.downloadQueue = [];
122
- this.uploading.clear();
123
90
  this.downloading.clear();
124
91
  this.remotePendingUploads = 0;
125
92
  this.lastErrorAtMs = null;
126
93
  this.emitState();
127
94
  }
128
- scheduleUpload() {
129
- if (this.uploadTimer) return;
130
- this.uploadTimer = setTimeout(() => {
131
- this.uploadTimer = null;
132
- void this.processNextUpload();
133
- }, this.opts.uploadThrottleMs ?? 300);
134
- }
135
95
  scheduleDownload() {
136
96
  if (this.downloadTimer) return;
137
97
  this.downloadTimer = setTimeout(() => {
@@ -139,50 +99,6 @@ var WhiteboardFilesSync = class {
139
99
  void this.processNextDownload();
140
100
  }, this.opts.downloadThrottleMs ?? 500);
141
101
  }
142
- async processNextUpload() {
143
- const fileId = this.uploadQueue.shift();
144
- if (!fileId) return;
145
- if (this.uploading.has(fileId)) return;
146
- const files = this.opts.excalidrawAPI.getFiles();
147
- const file = files[fileId];
148
- if (!file?.dataURL || !file?.mimeType) {
149
- if (this.uploadQueue.length > 0) this.scheduleUpload();
150
- return;
151
- }
152
- this.uploading.add(fileId);
153
- this.emitState();
154
- try {
155
- const blob = dataURLToBlob(file.dataURL);
156
- const metadata = {
157
- fileId,
158
- mimeType: file.mimeType,
159
- byteLength: blob.size,
160
- sha256: await sha256Hex(blob)
161
- };
162
- if (this.opts.fileTransfer) {
163
- await this.opts.fileTransfer.upload({ ...metadata, dataURL: file.dataURL });
164
- } else {
165
- const initiateUpload = requireOption(this.opts.initiateUpload, "initiateUpload");
166
- const finalizeUpload = requireOption(this.opts.finalizeUpload, "finalizeUpload");
167
- const instructions = await initiateUpload(metadata);
168
- const res = await fetch(instructions.uploadUrl, {
169
- method: instructions.method,
170
- headers: instructions.headers,
171
- body: blob
172
- });
173
- if (!res.ok) throw new Error(`upload failed: ${res.status}`);
174
- await finalizeUpload(instructions.uploadId);
175
- }
176
- updateImageStatus(this.opts.excalidrawAPI, fileId, "saved");
177
- } catch {
178
- this.lastErrorAtMs = Date.now();
179
- updateImageStatus(this.opts.excalidrawAPI, fileId, "error");
180
- } finally {
181
- this.uploading.delete(fileId);
182
- this.emitState();
183
- if (this.uploadQueue.length > 0) this.scheduleUpload();
184
- }
185
- }
186
102
  async processNextDownload() {
187
103
  const fileId = this.downloadQueue.shift();
188
104
  if (!fileId) return;
@@ -227,15 +143,11 @@ var WhiteboardFilesSync = class {
227
143
  }
228
144
  }
229
145
  emitState() {
230
- const uploadQueued = this.uploadQueue.length;
231
- const uploading = this.uploading.size;
232
146
  const downloadQueued = this.downloadQueue.length;
233
147
  const downloading = this.downloading.size;
234
- const phase = uploading > 0 || uploadQueued > 0 ? "uploading" : this.remotePendingUploads > 0 ? "awaiting_remote_upload" : downloading > 0 || downloadQueued > 0 ? "downloading" : this.lastErrorAtMs && Date.now() - this.lastErrorAtMs < 4e3 ? "error" : "idle";
148
+ const phase = this.remotePendingUploads > 0 ? "awaiting_remote_upload" : downloading > 0 || downloadQueued > 0 ? "downloading" : this.lastErrorAtMs && Date.now() - this.lastErrorAtMs < 4e3 ? "error" : "idle";
235
149
  this.opts.onStateChange?.({
236
150
  phase,
237
- uploading,
238
- uploadQueued,
239
151
  remotePendingUploads: this.remotePendingUploads,
240
152
  downloading,
241
153
  downloadQueued,
@@ -248,6 +160,15 @@ function requireOption(value, name) {
248
160
  return value;
249
161
  }
250
162
 
163
+ // src/collab/order.ts
164
+ function compareCodeUnits(left, right) {
165
+ if (left === right) return 0;
166
+ return left < right ? -1 : 1;
167
+ }
168
+ function compareElementOrder(left, right) {
169
+ return compareCodeUnits(left.index ?? "", right.index ?? "") || compareCodeUnits(left.id, right.id);
170
+ }
171
+
251
172
  // src/collab/presence.ts
252
173
  var CURSOR_COLORS = [
253
174
  { stroke: "#FF5D5D", background: "rgba(255, 93, 93, 0.2)" },
@@ -328,8 +249,12 @@ var filterSyncableElements = (elements) => elements.filter((el) => {
328
249
  if (el.isDeleted) return true;
329
250
  return !isInvisiblySmallElement(el);
330
251
  });
252
+ var remoteImageFileIds = (elements) => elements.flatMap((element) => element.type === "image" && element.fileId ? [element.fileId] : []);
253
+ var retainStoredImages = (elements, storedFileIds) => elements.filter((element) => element.type !== "image" || element.fileId !== null && storedFileIds.has(element.fileId));
331
254
 
332
255
  // src/collab/wire.ts
256
+ var ELEMENT_ENCODED_BYTES_LIMIT = 16384 - 64;
257
+ var encoder = new TextEncoder();
333
258
  var wireElementKeys = ["id", "type", "version", "version_nonce", "index", "is_deleted", "payload"];
334
259
  var wireElementValidators = [
335
260
  (value) => isNonEmptyString(value.id),
@@ -351,6 +276,47 @@ function toWireElement(element) {
351
276
  payload: toJsonRecord(element)
352
277
  };
353
278
  }
279
+ function toFittedWireElement(element) {
280
+ const wire = toWireElement(element);
281
+ if (fits(wire)) return wire;
282
+ const payload = fitPoints(element, wire);
283
+ if (!payload) throw new Error("Whiteboard element exceeds the whiteboard-v1 element size");
284
+ return { ...wire, payload };
285
+ }
286
+ function fitPoints(element, wire) {
287
+ if (!("points" in element) || isElbowArrow(element)) return void 0;
288
+ const points = element.points.map((point) => point.map(roundCoordinate));
289
+ const pressures = element.type === "freedraw" ? element.pressures.map(roundCoordinate) : void 0;
290
+ const sample = (stride) => ({ ...wire.payload, points: sampleValues(points, stride), ...pressures ? { pressures: sampleValues(pressures, stride) } : {} });
291
+ const lastStride = Math.max(1, points.length - 1);
292
+ if (!fits({ ...wire, payload: sample(lastStride) })) return void 0;
293
+ for (let stride = 1; stride < lastStride; stride++) {
294
+ const reduced = sample(stride);
295
+ if (fits({ ...wire, payload: reduced })) return reduced;
296
+ }
297
+ return sample(lastStride);
298
+ }
299
+ function isElbowArrow(element) {
300
+ return element.type === "arrow" && "elbowed" in element && element.elbowed;
301
+ }
302
+ var exponentWithoutFraction = /(^|[^\d.])\d[eE][+-]?\d/g;
303
+ function fits(element) {
304
+ const json = JSON.stringify(element);
305
+ return encoder.encode(json).byteLength + 2 * (json.match(exponentWithoutFraction)?.length ?? 0) <= ELEMENT_ENCODED_BYTES_LIMIT;
306
+ }
307
+ function roundCoordinate(value) {
308
+ return Number(value.toFixed(2));
309
+ }
310
+ function sampleValues(values, stride) {
311
+ const sampled = [];
312
+ for (let index = 0; index < values.length - 1; index += stride) {
313
+ const value = values[index];
314
+ if (value !== void 0) sampled.push(value);
315
+ }
316
+ const last = values.at(-1);
317
+ if (last !== void 0) sampled.push(last);
318
+ return sampled;
319
+ }
354
320
  function fromWireElement(element) {
355
321
  if (!isWireElement(element)) {
356
322
  throw new Error("invalid whiteboard-v1 element");
@@ -419,12 +385,16 @@ function hasExactKeys(value, keys) {
419
385
  }
420
386
 
421
387
  // src/collab/engine.ts
422
- var FULL_SYNC_INTERVAL_MS = 2e4;
388
+ var SUBMISSION_RETRY_DELAY_MS = 1e3;
389
+ var MAX_SUBMISSION_RETRY_DELAY_MS = 16e3;
423
390
  var CHANGE_DEBOUNCE_MS = 150;
391
+ var CHANGE_MAX_WAIT_MS = 1e3;
424
392
  var CURSOR_THROTTLE_MS = 16;
425
393
  var CURSOR_STALE_MS = 1e4;
426
394
  var asArray = (value) => Array.isArray(value) ? value : [];
427
395
  var toReconcileRemoteElements = (elements) => elements;
396
+ var isNewerVersion = (element, previous) => element.version > previous.version || element.version === previous.version && element.versionNonce < previous.versionNonce;
397
+ var editingElementIds = (appState) => [appState.newElement?.id, appState.resizingElement?.id, appState.editingTextElement?.id];
428
398
  var ExcalidrawCollabEngine = class {
429
399
  constructor(opts) {
430
400
  this.opts = opts;
@@ -432,10 +402,7 @@ var ExcalidrawCollabEngine = class {
432
402
  this.filesSync = new WhiteboardFilesSync({
433
403
  excalidrawAPI: opts.excalidrawAPI,
434
404
  fileTransfer: opts.fileTransfer,
435
- initiateUpload: opts.initiateUpload,
436
- finalizeUpload: opts.finalizeUpload,
437
405
  presignDownload: opts.presignDownload,
438
- uploadThrottleMs: 300,
439
406
  downloadThrottleMs: 500,
440
407
  onStateChange: opts.onFileSyncStateChange
441
408
  });
@@ -452,13 +419,17 @@ var ExcalidrawCollabEngine = class {
452
419
  sceneId = null;
453
420
  sceneGeneration = null;
454
421
  canDraw = true;
455
- lastBroadcastedOrReceivedElementsHash = 0;
422
+ storedImageFileIds = /* @__PURE__ */ new Set();
456
423
  broadcastedElementVersions = /* @__PURE__ */ new Map();
424
+ rejectedElementVersions = /* @__PURE__ */ new Map();
425
+ deferredRemoteElements = /* @__PURE__ */ new Map();
457
426
  submissionInFlight = false;
458
427
  dirtyDuringSubmission = false;
459
428
  disposed = false;
460
429
  changeDebounce = null;
461
- fullSyncTimer = null;
430
+ changeDebounceDeadline = null;
431
+ submissionRetryTimer = null;
432
+ submissionRetries = 0;
462
433
  filesSync;
463
434
  presence;
464
435
  unsubPointerUp;
@@ -469,10 +440,13 @@ var ExcalidrawCollabEngine = class {
469
440
  async clear() {
470
441
  if (!this.canDraw) throw new Error("whiteboard drawing is not permitted");
471
442
  const commit = await this.opts.clear();
443
+ this.storedImageFileIds.clear();
472
444
  this.sceneId = commit.sceneId;
473
445
  this.sceneGeneration = commit.sceneGeneration ?? null;
474
446
  this.broadcastedElementVersions.clear();
475
- this.lastBroadcastedOrReceivedElementsHash = 0;
447
+ this.rejectedElementVersions.clear();
448
+ this.deferredRemoteElements.clear();
449
+ this.resetSubmissionRetries();
476
450
  this.opts.excalidrawAPI.updateScene({
477
451
  elements: [],
478
452
  captureUpdate: CaptureUpdateAction2.NEVER
@@ -481,29 +455,36 @@ var ExcalidrawCollabEngine = class {
481
455
  }
482
456
  dispose() {
483
457
  if (this.disposed) return;
484
- const hasPendingChange = this.changeDebounce !== null || this.dirtyDuringSubmission;
458
+ const hasPendingChange = this.changeDebounce !== null || this.dirtyDuringSubmission || this.submissionRetryTimer !== null;
485
459
  this.disposed = true;
486
- if (this.changeDebounce) clearTimeout(this.changeDebounce);
487
- this.changeDebounce = null;
460
+ this.clearChangeDebounce();
488
461
  if (hasPendingChange) this.flushOnDispose();
489
- if (this.fullSyncTimer) clearTimeout(this.fullSyncTimer);
490
- this.fullSyncTimer = null;
462
+ if (this.submissionRetryTimer) clearTimeout(this.submissionRetryTimer);
463
+ this.submissionRetryTimer = null;
491
464
  this.dirtyDuringSubmission = false;
465
+ this.deferredRemoteElements.clear();
492
466
  this.filesSync.dispose();
493
467
  this.presence.dispose();
494
468
  this.unsubscribe();
495
469
  this.unsubPointerUp?.();
496
470
  }
497
- handleChange(_elements, _appState, files) {
471
+ handleChange(_elements, _appState, _files) {
498
472
  if (this.disposed) return;
499
- const elementsAll = this.opts.excalidrawAPI.getSceneElementsIncludingDeleted();
500
- this.filesSync.handleLocalScene(elementsAll, files);
473
+ this.applyDeferredRemoteElements();
474
+ this.stripUnstoredImages();
501
475
  if (!this.canDraw) return;
502
- if (this.changeDebounce) clearTimeout(this.changeDebounce);
503
- this.changeDebounce = setTimeout(() => {
504
- this.changeDebounce = null;
505
- this.flushNow();
506
- }, CHANGE_DEBOUNCE_MS);
476
+ const now = Date.now();
477
+ const deadline = this.changeDebounceDeadline ?? now + CHANGE_MAX_WAIT_MS;
478
+ const wait = Math.min(CHANGE_DEBOUNCE_MS, deadline - now);
479
+ this.clearChangeDebounce();
480
+ this.changeDebounceDeadline = deadline;
481
+ this.changeDebounce = setTimeout(
482
+ () => {
483
+ this.clearChangeDebounce();
484
+ this.flushNow(wait < CHANGE_DEBOUNCE_MS);
485
+ },
486
+ Math.max(0, wait)
487
+ );
507
488
  }
508
489
  handlePointerUpdate(payload) {
509
490
  this.presence.handlePointerUpdate(payload);
@@ -561,49 +542,72 @@ var ExcalidrawCollabEngine = class {
561
542
  isSnapshot: true
562
543
  });
563
544
  }
564
- flushNow() {
565
- if (this.disposed) return;
545
+ stripUnstoredImages() {
546
+ const elements = this.opts.excalidrawAPI.getSceneElementsIncludingDeleted();
547
+ const retained = retainStoredImages(elements, this.storedImageFileIds);
548
+ if (retained.length === elements.length) return;
549
+ this.opts.excalidrawAPI.updateScene({ elements: retained, captureUpdate: CaptureUpdateAction2.NEVER });
550
+ }
551
+ flushNow(skipEditing = false) {
552
+ if (this.disposed || this.submissionRetryTimer !== null) return;
566
553
  const context = this.submissionContext();
567
554
  if (!context) return;
568
- const elementsHash = hashElementsVersion(context.elements);
569
- if (elementsHash === this.lastBroadcastedOrReceivedElementsHash) return;
570
- const syncableAll = filterSyncableElements(context.elements);
555
+ const editing = skipEditing ? editingElementIds(this.opts.excalidrawAPI.getAppState()) : [];
556
+ const syncableAll = filterSyncableElements(retainStoredImages(context.elements, this.storedImageFileIds));
571
557
  const delta = [];
572
558
  for (const el of syncableAll) {
573
- const prev = this.broadcastedElementVersions.get(el.id) ?? 0;
574
- if (!prev || el.version > prev) delta.push(el);
559
+ if (this.isUnsubmitted(el) && !editing.includes(el.id)) delta.push(el);
575
560
  }
576
561
  if (delta.length === 0) return;
577
- this.submit(context.sceneId, context.sceneGeneration ?? null, false, delta, elementsHash);
562
+ this.submit(context.sceneId, context.sceneGeneration ?? null, delta);
578
563
  }
579
564
  flushOnDispose() {
580
565
  const context = this.submissionContext({ allowDisposed: true, allowInFlight: true });
581
566
  if (!context) return;
582
- const elementsHash = hashElementsVersion(context.elements);
583
- if (elementsHash === this.lastBroadcastedOrReceivedElementsHash) return;
584
- const syncableAll = filterSyncableElements(context.elements);
567
+ const syncableAll = filterSyncableElements(retainStoredImages(context.elements, this.storedImageFileIds));
585
568
  const delta = [];
586
569
  for (const element of syncableAll) {
587
- const previousVersion = this.broadcastedElementVersions.get(element.id) ?? 0;
588
- if (!previousVersion || element.version > previousVersion) delta.push(element);
570
+ if (this.isUnsubmitted(element)) delta.push(element);
589
571
  }
590
572
  if (delta.length === 0) return;
591
573
  this.submitFinalUpdate(context.sceneId, context.sceneGeneration ?? null, delta);
592
574
  }
593
- scheduleFullSync() {
594
- if (this.disposed || this.fullSyncTimer) return;
595
- this.fullSyncTimer = setTimeout(() => {
596
- this.fullSyncTimer = null;
597
- if (this.disposed) return;
598
- this.sendFullSync();
599
- }, FULL_SYNC_INTERVAL_MS);
575
+ isUnsubmitted(element) {
576
+ const rejected = this.rejectedElementVersions.get(element.id);
577
+ if (rejected?.version === element.version && rejected.versionNonce === element.versionNonce) return false;
578
+ const previous = this.broadcastedElementVersions.get(element.id);
579
+ return !previous || isNewerVersion(element, previous);
580
+ }
581
+ applyDeferredRemoteElements() {
582
+ if (!this.sceneId || this.deferredRemoteElements.size === 0) return;
583
+ const editing = editingElementIds(this.opts.excalidrawAPI.getAppState());
584
+ const elements = [];
585
+ for (const [id, element] of this.deferredRemoteElements) {
586
+ if (editing.includes(id)) continue;
587
+ this.deferredRemoteElements.delete(id);
588
+ elements.push(element);
589
+ }
590
+ if (elements.length === 0) return;
591
+ this.applyRemoteElements({ sceneId: this.sceneId, ...this.sceneGeneration ? { sceneGeneration: this.sceneGeneration } : {}, syncAll: false, remoteElements: elements, isSnapshot: false });
600
592
  }
601
- sendFullSync() {
602
- if (this.disposed) return;
603
- const context = this.submissionContext();
604
- if (!context) return;
605
- const syncableAll = filterSyncableElements(context.elements);
606
- this.submit(context.sceneId, context.sceneGeneration ?? null, true, syncableAll, hashElementsVersion(context.elements));
593
+ clearChangeDebounce() {
594
+ if (this.changeDebounce !== null) clearTimeout(this.changeDebounce);
595
+ this.changeDebounce = null;
596
+ this.changeDebounceDeadline = null;
597
+ }
598
+ resetSubmissionRetries() {
599
+ if (this.submissionRetryTimer !== null) clearTimeout(this.submissionRetryTimer);
600
+ this.submissionRetryTimer = null;
601
+ this.submissionRetries = 0;
602
+ }
603
+ scheduleSubmissionRetry() {
604
+ if (this.disposed || this.submissionRetryTimer !== null) return;
605
+ const delay = Math.min(SUBMISSION_RETRY_DELAY_MS * 2 ** this.submissionRetries, MAX_SUBMISSION_RETRY_DELAY_MS);
606
+ this.submissionRetries += 1;
607
+ this.submissionRetryTimer = setTimeout(() => {
608
+ this.submissionRetryTimer = null;
609
+ this.flushNow();
610
+ }, delay);
607
611
  }
608
612
  submissionContext(options = {}) {
609
613
  if (this.disposed && !options.allowDisposed) return null;
@@ -622,41 +626,54 @@ var ExcalidrawCollabEngine = class {
622
626
  elements: this.opts.excalidrawAPI.getSceneElementsIncludingDeleted()
623
627
  };
624
628
  }
625
- submit(sceneId, sceneGeneration, syncAll, elements, elementsHash) {
629
+ submit(sceneId, sceneGeneration, elements) {
630
+ const submittedElements = this.toSubmissionElements(elements);
631
+ if (submittedElements.length === 0) return;
626
632
  this.submissionInFlight = true;
627
633
  this.dirtyDuringSubmission = false;
628
- void this.opts.submitUpdate({
629
- sceneId,
630
- ...sceneGeneration ? { sceneGeneration } : {},
631
- syncAll,
632
- elements: elements.map(toWireElement)
633
- }).then((commit) => {
634
+ let acknowledged = false;
635
+ void Promise.resolve().then(
636
+ () => this.opts.submitUpdate({
637
+ sceneId,
638
+ ...sceneGeneration ? { sceneGeneration } : {},
639
+ syncAll: false,
640
+ elements: submittedElements
641
+ })
642
+ ).then((commit) => {
634
643
  if (this.disposed) return;
635
644
  if (commit.sceneId !== sceneId || this.sceneId !== sceneId || this.sceneGeneration !== sceneGeneration) {
636
645
  this.requestSnapshot();
637
646
  return;
638
647
  }
639
- for (const element of elements) {
640
- this.broadcastedElementVersions.set(element.id, element.version);
648
+ for (const element of submittedElements) {
649
+ const previous = this.broadcastedElementVersions.get(element.id);
650
+ const submittedVersion = { version: element.version, versionNonce: element.version_nonce };
651
+ if (!previous || isNewerVersion(submittedVersion, previous)) this.broadcastedElementVersions.set(element.id, submittedVersion);
641
652
  }
642
- this.lastBroadcastedOrReceivedElementsHash = elementsHash;
643
- this.scheduleFullSync();
644
- }).catch(this.opts.onSubmissionError).finally(() => {
653
+ this.submissionRetries = 0;
654
+ acknowledged = true;
655
+ }).catch((cause) => {
656
+ if (this.sceneId === sceneId && this.sceneGeneration === sceneGeneration) this.scheduleSubmissionRetry();
657
+ this.opts.onSubmissionError?.(cause);
658
+ }).finally(() => {
645
659
  if (this.disposed) return;
646
660
  this.submissionInFlight = false;
647
661
  const shouldFlush = this.dirtyDuringSubmission;
648
662
  this.dirtyDuringSubmission = false;
649
- if (!this.disposed && shouldFlush) this.flushNow();
663
+ if (shouldFlush) this.flushNow();
664
+ else if (acknowledged && this.changeDebounce === null) this.flushNow(true);
650
665
  });
651
666
  }
652
667
  submitFinalUpdate(sceneId, sceneGeneration, elements) {
668
+ const submittedElements = this.toSubmissionElements(elements);
669
+ if (submittedElements.length === 0) return;
653
670
  let submission;
654
671
  try {
655
672
  submission = this.opts.submitUpdate({
656
673
  sceneId,
657
674
  ...sceneGeneration ? { sceneGeneration } : {},
658
675
  syncAll: false,
659
- elements: elements.map(toWireElement)
676
+ elements: submittedElements
660
677
  });
661
678
  } catch (cause) {
662
679
  this.opts.onSubmissionError?.(cause);
@@ -664,6 +681,20 @@ var ExcalidrawCollabEngine = class {
664
681
  }
665
682
  void submission.catch((cause) => this.opts.onSubmissionError?.(cause));
666
683
  }
684
+ toSubmissionElements(elements) {
685
+ const submitted = [];
686
+ for (const element of elements) {
687
+ try {
688
+ submitted.push(toFittedWireElement(element));
689
+ this.rejectedElementVersions.delete(element.id);
690
+ } catch (cause) {
691
+ const reported = this.rejectedElementVersions.has(element.id);
692
+ this.rejectedElementVersions.set(element.id, { version: element.version, versionNonce: element.versionNonce });
693
+ if (!reported) this.opts.onSubmissionError?.(new Error("One whiteboard item could not be shared. If it is very large, make it smaller or split it.", { cause }));
694
+ }
695
+ }
696
+ return submitted;
697
+ }
667
698
  applyRemoteElements(args) {
668
699
  const remoteSceneId = args.sceneId;
669
700
  const replacingScene = this.sceneId !== null && remoteSceneId !== this.sceneId;
@@ -673,43 +704,58 @@ var ExcalidrawCollabEngine = class {
673
704
  if (remoteSceneId !== this.sceneId) {
674
705
  this.sceneId = remoteSceneId;
675
706
  this.broadcastedElementVersions.clear();
676
- this.lastBroadcastedOrReceivedElementsHash = 0;
677
707
  }
678
708
  } else if (remoteSceneId !== this.sceneId) {
679
709
  if (args.syncAll && asArray(args.remoteElements).length === 0) {
680
710
  this.sceneId = remoteSceneId;
681
711
  this.broadcastedElementVersions.clear();
682
- this.lastBroadcastedOrReceivedElementsHash = 0;
683
712
  } else {
684
713
  this.requestSnapshot();
685
714
  return;
686
715
  }
687
716
  }
688
717
  if (replacingScene) {
689
- if (this.changeDebounce !== null) clearTimeout(this.changeDebounce);
690
- this.changeDebounce = null;
718
+ this.storedImageFileIds.clear();
719
+ this.rejectedElementVersions.clear();
720
+ this.clearChangeDebounce();
691
721
  this.dirtyDuringSubmission = false;
692
722
  this.sceneGeneration = null;
723
+ this.deferredRemoteElements.clear();
724
+ this.resetSubmissionRetries();
693
725
  }
694
726
  if (args.sceneGeneration) this.sceneGeneration = args.sceneGeneration;
695
727
  const excalidrawAPI = this.opts.excalidrawAPI;
696
728
  if (replacingScene) excalidrawAPI.history.clear();
697
729
  const local = replacingScene ? [] : excalidrawAPI.getSceneElementsIncludingDeleted();
698
- const remoteElements = args.remoteElements;
699
- const restoredRemote = restoreElements(remoteElements, local);
730
+ const remoteElements = [...args.remoteElements].sort(compareElementOrder);
731
+ const restoredRemote = restoreElements(remoteElements, null);
732
+ this.storedImageFileIds = /* @__PURE__ */ new Set([...this.storedImageFileIds, ...remoteImageFileIds(restoredRemote)]);
700
733
  const remoteForReconcile = toReconcileRemoteElements(restoredRemote);
701
- const reconciled = reconcileElements(local, remoteForReconcile, excalidrawAPI.getAppState());
702
- this.lastBroadcastedOrReceivedElementsHash = hashElementsVersion(reconciled);
703
- this.broadcastedElementVersions.clear();
704
- for (const el of reconciled) {
705
- this.broadcastedElementVersions.set(el.id, el.version);
734
+ const appState = excalidrawAPI.getAppState();
735
+ const editing = editingElementIds(appState);
736
+ const reconciled = retainStoredImages(reconcileElements(local, remoteForReconcile, appState), this.storedImageFileIds);
737
+ if (args.isSnapshot) {
738
+ this.broadcastedElementVersions.clear();
739
+ this.resetSubmissionRetries();
740
+ }
741
+ const remoteById = new Map(restoredRemote.map((element) => [element.id, element]));
742
+ for (const element of reconciled) {
743
+ const remote = remoteById.get(element.id);
744
+ if (remote?.version === element.version && remote.versionNonce === element.versionNonce) {
745
+ this.broadcastedElementVersions.set(element.id, { version: element.version, versionNonce: element.versionNonce });
746
+ } else if (remote && editing.includes(element.id) && isNewerVersion(remote, element)) {
747
+ const previous = this.deferredRemoteElements.get(element.id);
748
+ if (!previous || isNewerVersion(remote, previous)) this.deferredRemoteElements.set(element.id, remote);
749
+ }
706
750
  }
751
+ const hasUnsubmittedElements = reconciled.some((element) => this.isUnsubmitted(element));
707
752
  excalidrawAPI.updateScene({
708
753
  elements: reconciled,
709
754
  appState: withLocalWhiteboardCamera(args.appState, excalidrawAPI.getAppState()),
710
755
  captureUpdate: CaptureUpdateAction2.NEVER
711
756
  });
712
757
  this.filesSync.handleRemoteScene(reconciled);
758
+ if (args.isSnapshot && hasUnsubmittedElements) queueMicrotask(() => this.flushNow());
713
759
  }
714
760
  requestSnapshot() {
715
761
  if (this.disposed) return;
@@ -721,6 +767,7 @@ var ExcalidrawCollabEngine = class {
721
767
  };
722
768
 
723
769
  export {
770
+ compareElementOrder,
724
771
  toWireElement,
725
772
  fromWireElement,
726
773
  isWireElement,
@@ -1,3 +1,7 @@
1
+ import {
2
+ compareElementOrder
3
+ } from "./chunk-6ZV7GBAC.js";
4
+
1
5
  // src/collab/reducer.ts
2
6
  function mergeWhiteboardElements(current, incoming) {
3
7
  const merged = new Map(current.map((element) => [element.id, element]));
@@ -13,10 +17,6 @@ function wins(candidate, existing) {
13
17
  if (candidate.version !== existing.version) return candidate.version > existing.version;
14
18
  return candidate.version_nonce < existing.version_nonce;
15
19
  }
16
- function compareElementOrder(left, right) {
17
- const byIndex = left.index.localeCompare(right.index);
18
- return byIndex === 0 ? left.id.localeCompare(right.id) : byIndex;
19
- }
20
20
 
21
21
  export {
22
22
  mergeWhiteboardElements