wordinweb 0.2.2 → 0.2.4

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/dist/collab.js CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  moveTableTo,
43
43
  parseXml,
44
44
  recordedProvenance,
45
+ rejectAllRevisions,
45
46
  rejectRevision,
46
47
  removeDrawingRun,
47
48
  removeLink,
@@ -78,7 +79,7 @@ import {
78
79
  setSmartArtTextFormat,
79
80
  toggleCheckbox,
80
81
  validatePastedOoxml
81
- } from "./chunk-JGXXZTQE.js";
82
+ } from "./chunk-QH2L3NPL.js";
82
83
  import "./chunk-7D4SUZUM.js";
83
84
 
84
85
  // src/collab.tsx
@@ -185,6 +186,7 @@ function runEditsOf(intent) {
185
186
  case "acceptRevision":
186
187
  case "rejectRevision":
187
188
  case "acceptAllRevisions":
189
+ case "rejectAllRevisions":
188
190
  return [];
189
191
  }
190
192
  }
@@ -327,6 +329,7 @@ function transformIntent(intent, ahead) {
327
329
  case "acceptRevision":
328
330
  case "rejectRevision":
329
331
  case "acceptAllRevisions":
332
+ case "rejectAllRevisions":
330
333
  return { ...intent, base: newBase };
331
334
  case "deleteText": {
332
335
  const s = transformPosition({ blockId: intent.blockId, runId: intent.runId, offset: intent.start }, ahead);
@@ -414,7 +417,15 @@ function applyIntentInner(doc, ids, intent, out) {
414
417
  case "splitParagraph": {
415
418
  const caret = resolveCaret(ids, runOf, intent.at);
416
419
  if (!caret) return false;
417
- const res = applySplitParagraph(doc, caret, ctx);
420
+ const splitCtx = intent.suggest ? {
421
+ suggesting: true,
422
+ revMeta: () => ({
423
+ author: intent.suggest.author,
424
+ date: intent.suggest.date,
425
+ nextId: () => doc.nextRevisionId()
426
+ })
427
+ } : ctx;
428
+ const res = applySplitParagraph(doc, caret, splitCtx);
418
429
  if (!res) return false;
419
430
  ids.assign(res.after, intent.newBlockId);
420
431
  const newRun = res.after.children.find((c) => localRun(c));
@@ -908,6 +919,8 @@ function applyIntentInner(doc, ids, intent, out) {
908
919
  }
909
920
  case "acceptAllRevisions":
910
921
  return acceptAllRevisions(doc) > 0;
922
+ case "rejectAllRevisions":
923
+ return rejectAllRevisions(doc) > 0;
911
924
  case "insertTable": {
912
925
  const runEl = ids.elOf(intent.runId);
913
926
  if (!runEl) return false;
@@ -1074,7 +1087,7 @@ function tableOfParagraph(doc, ids, cellParagraphId) {
1074
1087
  return null;
1075
1088
  }
1076
1089
  function firstDrawingIn(el) {
1077
- if (localName(el.name) === "drawing") return el;
1090
+ if (["drawing", "shape", "rect"].includes(localName(el.name))) return el;
1078
1091
  for (const c of el.children) {
1079
1092
  const found = firstDrawingIn(c);
1080
1093
  if (found) return found;
@@ -1511,7 +1524,8 @@ var InMemoryBundleStore = class {
1511
1524
  ...parseBundleKey(b.docId),
1512
1525
  key: b.docId,
1513
1526
  savedAt: b.savedAt,
1514
- byteLength: b.confirmedBytes.byteLength
1527
+ byteLength: b.confirmedBytes.byteLength,
1528
+ title: b.title
1515
1529
  }));
1516
1530
  }
1517
1531
  };
@@ -1588,6 +1602,7 @@ var BundlePersister = class {
1588
1602
  if (!bundle) return;
1589
1603
  bundle.savedAt = this.lastWrite;
1590
1604
  const prior = await this.store.get(this.docId);
1605
+ bundle.title = prior?.title;
1591
1606
  const chain = [...prior?.lineage ?? []];
1592
1607
  const digest = await crypto.subtle.digest("SHA-256", bundle.confirmedBytes);
1593
1608
  let hash = "";
@@ -1600,6 +1615,7 @@ var BundlePersister = class {
1600
1615
  if (off && off.tail.length) {
1601
1616
  bundle.offlineTail = off.tail;
1602
1617
  bundle.offlineTailEpoch = off.epoch;
1618
+ bundle.offlineBaseSeq = off.baseSeq ?? bundle.confirmedSeq;
1603
1619
  }
1604
1620
  await this.store.put(bundle);
1605
1621
  }
@@ -1619,6 +1635,14 @@ function toSuggestions(tail, author, date) {
1619
1635
  ranges: [{ blockId: intent.blockId, runId: intent.runId, start: intent.start, end: intent.end }],
1620
1636
  suggest: { author, date }
1621
1637
  });
1638
+ } else if (intent.kind === "splitParagraph") {
1639
+ suggestions.push({
1640
+ kind: "splitParagraph",
1641
+ at: intent.at,
1642
+ newBlockId: intent.newBlockId,
1643
+ newRunId: intent.newRunId,
1644
+ suggest: { author, date }
1645
+ });
1622
1646
  } else {
1623
1647
  dropped.push(intent);
1624
1648
  }
@@ -1632,7 +1656,7 @@ function arrivalMode(tailLength, diverged, opts = {}) {
1632
1656
  }
1633
1657
 
1634
1658
  // ../collab/src/protocol.ts
1635
- var ENGINE_VERSION = "e7";
1659
+ var ENGINE_VERSION = "e10";
1636
1660
  var PRESENCE_MAX_RANGES = 64;
1637
1661
  function sanitizePresencePosition(pos) {
1638
1662
  if (!pos || !pos.ranges) return pos;
@@ -1648,7 +1672,20 @@ function sanitizePresencePosition(pos) {
1648
1672
  if (clean.length === pos.ranges.length) return pos;
1649
1673
  return clean.length > 0 ? { ...pos, ranges: clean } : { ...pos, ranges: void 0 };
1650
1674
  }
1651
- var PROTOCOL_VERSION = 3;
1675
+ var PROTOCOL_VERSION = 5;
1676
+
1677
+ // ../collab/src/id-allocator.ts
1678
+ var CarriedIdAllocator = class {
1679
+ constructor(clientId) {
1680
+ this.counter = 0;
1681
+ let hash = 0;
1682
+ for (let i = 0; i < clientId.length; i++) hash = hash * 31 + clientId.charCodeAt(i) >>> 0;
1683
+ this.base = 1e9 + hash % 1e5 * 1e7;
1684
+ }
1685
+ next() {
1686
+ return this.base + this.counter++;
1687
+ }
1688
+ };
1652
1689
 
1653
1690
  // ../collab/src/media.ts
1654
1691
  function mediaAddressesOf(doc) {
@@ -2081,11 +2118,6 @@ var _CollabConnection = class _CollabConnection {
2081
2118
  * the renderer re-mounts on this, but updates in place otherwise (no flash
2082
2119
  * for the common non-conflicting edits). */
2083
2120
  this.docEpoch = 0;
2084
- /** Per-client id allocator for carried node ids (split/format-range/insert):
2085
- * a client-specific base block keeps concurrently-allocated ids disjoint
2086
- * across clients so they never collide. */
2087
- this.idCounter = 0;
2088
- this.idBase = -1;
2089
2121
  /** The epoch id of the session this connection joined (from the welcome).
2090
2122
  * The resume layer compares it against the bundle's stored genesisId:
2091
2123
  * same ⇒ seamless rejoin; different ⇒ someone re-seeded while away
@@ -2137,6 +2169,7 @@ var _CollabConnection = class _CollabConnection {
2137
2169
  this.droppedPreReady = 0;
2138
2170
  this.rlRedriveTimer = null;
2139
2171
  this.rlRedriveBackoffMs = 0;
2172
+ this.idAllocator = new CarriedIdAllocator(clientId);
2140
2173
  this.transport.onMessage((msg) => this.onServer(msg));
2141
2174
  if (mediaOpts) {
2142
2175
  this.media = new MediaClient(mediaOpts, () => this.replica?.doc ?? null, {
@@ -2163,13 +2196,11 @@ var _CollabConnection = class _CollabConnection {
2163
2196
  }
2164
2197
  /** Allocate `n` fresh carried node ids in this client's disjoint block. */
2165
2198
  allocIds(n) {
2166
- if (this.idBase < 0) {
2167
- let h = 0;
2168
- for (let i = 0; i < this.clientId.length; i++) h = h * 31 + this.clientId.charCodeAt(i) >>> 0;
2169
- this.idBase = 1e9 + h % 1e5 * 1e7;
2170
- }
2171
2199
  const out = [];
2172
- for (let i = 0; i < n; i++) out.push(this.idBase + this.idCounter++);
2200
+ while (out.length < n) {
2201
+ const id = this.idAllocator.next();
2202
+ if (!this.replica?.doc.stableIds?.elOf(id)) out.push(id);
2203
+ }
2173
2204
  return out;
2174
2205
  }
2175
2206
  /**
@@ -2627,6 +2658,10 @@ function validateIntent(intent, limits = DEFAULT_INTENT_LIMITS) {
2627
2658
  case "splitParagraph":
2628
2659
  if (!nonNegInt(intent.at.offset)) return "splitParagraph: bad offset";
2629
2660
  if (!nonNegInt(intent.newBlockId) || !nonNegInt(intent.newRunId)) return "splitParagraph: bad ids";
2661
+ if (intent.suggest !== void 0) {
2662
+ if (typeof intent.suggest.author !== "string" || intent.suggest.author.length > 100) return "splitParagraph: bad author";
2663
+ if (typeof intent.suggest.date !== "string" || intent.suggest.date.length > 40) return "splitParagraph: bad date";
2664
+ }
2630
2665
  return null;
2631
2666
  case "formatRange":
2632
2667
  if (!nonNegInt(intent.start) || !nonNegInt(intent.end) || intent.end <= intent.start) return "formatRange: bad range";
@@ -2841,6 +2876,7 @@ function validateIntent(intent, limits = DEFAULT_INTENT_LIMITS) {
2841
2876
  if (!nonNegInt(intent.index) || intent.index > 1e5) return `${intent.kind}: bad index`;
2842
2877
  return null;
2843
2878
  case "acceptAllRevisions":
2879
+ case "rejectAllRevisions":
2844
2880
  return null;
2845
2881
  case "insertTable": {
2846
2882
  const okDim = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 50;
@@ -3216,8 +3252,6 @@ var _EncryptedCollabConnection = class _EncryptedCollabConnection {
3216
3252
  /** Envelope processing is async (WebCrypto) but MUST be strictly ordered
3217
3253
  * (seq order is the convergence contract) — a serial promise chain. */
3218
3254
  this.queue = Promise.resolve();
3219
- this.idCounter = 0;
3220
- this.idBase = -1;
3221
3255
  /** Own doc hashes at gossip points (seq → hash), a small ring — peers'
3222
3256
  * gossip for a seq we also hashed is comparable; anything else is skipped
3223
3257
  * (clients hash at the SAME seq cadence, so overlap is the common case). */
@@ -3306,6 +3340,7 @@ var _EncryptedCollabConnection = class _EncryptedCollabConnection {
3306
3340
  this.presenceInQueue = Promise.resolve();
3307
3341
  this.presenceLatest = null;
3308
3342
  this.presenceFlushing = false;
3343
+ this.idAllocator = new CarriedIdAllocator(clientId);
3309
3344
  this.transport.onMessage((msg) => this.onServer(msg));
3310
3345
  if (mediaOpts) {
3311
3346
  this.media = new MediaClient(
@@ -3457,13 +3492,11 @@ var _EncryptedCollabConnection = class _EncryptedCollabConnection {
3457
3492
  }
3458
3493
  /** Same disjoint carried-id allocation as the plaintext connection. */
3459
3494
  allocIds(n) {
3460
- if (this.idBase < 0) {
3461
- let h = 0;
3462
- for (let i = 0; i < this.clientId.length; i++) h = h * 31 + this.clientId.charCodeAt(i) >>> 0;
3463
- this.idBase = 1e9 + h % 1e5 * 1e7;
3464
- }
3465
3495
  const out = [];
3466
- for (let i = 0; i < n; i++) out.push(this.idBase + this.idCounter++);
3496
+ while (out.length < n) {
3497
+ const id = this.idAllocator.next();
3498
+ if (!this.replica?.doc.stableIds?.elOf(id)) out.push(id);
3499
+ }
3467
3500
  return out;
3468
3501
  }
3469
3502
  /** Submit a local edit (optimistic canonical apply, like the plaintext
@@ -4151,7 +4184,12 @@ var IndexedDbBundleStore = class {
4151
4184
  await new Promise((resolve, reject) => {
4152
4185
  const t = db.transaction(["bundles", "meta"], "readwrite");
4153
4186
  t.objectStore("bundles").put(bundle);
4154
- const meta = { docId: bundle.docId, savedAt: bundle.savedAt, byteLength: bundle.confirmedBytes.byteLength };
4187
+ const meta = {
4188
+ docId: bundle.docId,
4189
+ savedAt: bundle.savedAt,
4190
+ byteLength: bundle.confirmedBytes.byteLength,
4191
+ title: bundle.title
4192
+ };
4155
4193
  t.objectStore("meta").put(meta);
4156
4194
  t.oncomplete = () => resolve();
4157
4195
  t.onerror = () => reject(t.error);
@@ -4181,7 +4219,12 @@ var IndexedDbBundleStore = class {
4181
4219
  budget--;
4182
4220
  const bundle = await this.get(key);
4183
4221
  if (!bundle) continue;
4184
- meta = { docId: key, savedAt: bundle.savedAt, byteLength: bundle.confirmedBytes.byteLength };
4222
+ meta = {
4223
+ docId: key,
4224
+ savedAt: bundle.savedAt,
4225
+ byteLength: bundle.confirmedBytes.byteLength,
4226
+ title: bundle.title
4227
+ };
4185
4228
  await this.tx("meta", "readwrite", (t) => t.objectStore("meta").put(meta));
4186
4229
  }
4187
4230
  out.push({
@@ -4190,7 +4233,8 @@ var IndexedDbBundleStore = class {
4190
4233
  // Unmeasured legacy record: 0 means UNKNOWN. A byte-budget caller must
4191
4234
  // not treat it as free, and must not delete it for being large either.
4192
4235
  savedAt: meta?.savedAt ?? 0,
4193
- byteLength: meta?.byteLength ?? 0
4236
+ byteLength: meta?.byteLength ?? 0,
4237
+ title: meta?.title
4194
4238
  });
4195
4239
  }
4196
4240
  return out;
@@ -4198,6 +4242,7 @@ var IndexedDbBundleStore = class {
4198
4242
  };
4199
4243
 
4200
4244
  // src/collab.tsx
4245
+ var EMPTY_PRESENCE = {};
4201
4246
  var TIMED_OUT = /* @__PURE__ */ Symbol("store-timeout");
4202
4247
  var STORE_DEADLINE_MS = 2e3;
4203
4248
  function withDeadline(p, ms, fallback) {
@@ -4229,13 +4274,22 @@ function withDeadline(p, ms, fallback) {
4229
4274
  }
4230
4275
  function useCollab(opts) {
4231
4276
  const { url, docId, clientId, token, createSocket, store, profile, takeover, docKey, shareCode, ownerToken, httpBase } = opts;
4277
+ const startOffline = Boolean(opts.startOffline);
4232
4278
  const connRef = useRef(null);
4279
+ const carriedIdAllocator = useMemo(() => new CarriedIdAllocator(clientId), [clientId]);
4233
4280
  const [doc, setDoc] = useState(null);
4281
+ const docRef = useRef(null);
4282
+ const offlineActiveRef = useRef(false);
4283
+ const offlineCappedRef = useRef(false);
4284
+ const captureOfflineRef = useRef(() => {
4285
+ });
4234
4286
  const [version, setVersion] = useState(0);
4235
4287
  const [renderVersion, setRenderVersion] = useState(0);
4236
4288
  const lastDocVersionRef = useRef(0);
4237
4289
  const renderScopeRef = useRef(null);
4238
4290
  const [docEpoch, setDocEpoch] = useState(0);
4291
+ const publishedDocRef = useRef(null);
4292
+ const connectionDocEpochRef = useRef(0);
4239
4293
  const [ready, setReady] = useState(false);
4240
4294
  const [presence, setPresence] = useState({});
4241
4295
  const [refused, setRefused] = useState(null);
@@ -4264,11 +4318,18 @@ function useCollab(opts) {
4264
4318
  const persisterRef = useRef(null);
4265
4319
  const tailConnRef = useRef(null);
4266
4320
  const offlinePersistTimerRef = useRef(null);
4267
- const [connection, setConnection] = useState("live");
4321
+ const [connection, setConnection] = useState(startOffline ? "lost" : "live");
4322
+ const connectionRef = useRef(startOffline ? "lost" : "live");
4268
4323
  const [reconnectNonce, setReconnectNonce] = useState(0);
4269
4324
  const backoffRef = useRef(0);
4270
4325
  const attemptRef = useRef(0);
4271
4326
  const retryTimerRef = useRef(null);
4327
+ const activeSocketRef = useRef(null);
4328
+ const manuallyOfflineRef = useRef(startOffline);
4329
+ const disconnectSeqRef = useRef(null);
4330
+ const manualDraftBankedRef = useRef(false);
4331
+ const manualDraftSnapshotRef = useRef(null);
4332
+ const offlineBaseBundleRef = useRef(null);
4272
4333
  const flushDoneRef = useRef(Promise.resolve());
4273
4334
  const maxRetries = opts.liveness?.maxRetries ?? 6;
4274
4335
  const livenessRef = useRef(opts.liveness);
@@ -4278,9 +4339,41 @@ function useCollab(opts) {
4278
4339
  retryTimerRef.current = null;
4279
4340
  backoffRef.current = 0;
4280
4341
  attemptRef.current = 0;
4342
+ if (manuallyOfflineRef.current) {
4343
+ const currentDoc = docRef.current;
4344
+ const currentBundle = connRef.current?.exportBundle(docId) ?? offlineBaseBundleRef.current;
4345
+ const ids = currentDoc?.stableIds;
4346
+ manualDraftSnapshotRef.current = currentDoc && currentBundle && ids ? {
4347
+ ...currentBundle,
4348
+ confirmedBytes: currentDoc.save(),
4349
+ confirmedSidecar: ids.exportSidecar(currentDoc.editableRoots()),
4350
+ pending: [],
4351
+ offlineTail: void 0,
4352
+ offlineTailEpoch: void 0,
4353
+ offlineBaseSeq: void 0,
4354
+ savedAt: Date.now()
4355
+ } : null;
4356
+ }
4357
+ manuallyOfflineRef.current = false;
4358
+ connectionRef.current = "reconnecting";
4281
4359
  setConnection("reconnecting");
4282
4360
  setReconnectNonce((n) => n + 1);
4283
4361
  };
4362
+ const disconnect = () => {
4363
+ if (manuallyOfflineRef.current) return;
4364
+ disconnectSeqRef.current = connRef.current?.exportBundle(docId)?.confirmedSeq ?? null;
4365
+ manualDraftBankedRef.current = false;
4366
+ manualDraftSnapshotRef.current = null;
4367
+ manuallyOfflineRef.current = true;
4368
+ if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current);
4369
+ retryTimerRef.current = null;
4370
+ connectionRef.current = "lost";
4371
+ setConnection("lost");
4372
+ try {
4373
+ activeSocketRef.current?.close();
4374
+ } catch {
4375
+ }
4376
+ };
4284
4377
  const persistOfflineTail = () => {
4285
4378
  if (!store) return;
4286
4379
  flushDoneRef.current = flushDoneRef.current.then(async () => {
@@ -4291,9 +4384,11 @@ function useCollab(opts) {
4291
4384
  if (tail.length) {
4292
4385
  next.offlineTail = [...tail];
4293
4386
  next.offlineTailEpoch = offlineEpochRef.current ?? void 0;
4387
+ next.offlineBaseSeq = disconnectSeqRef.current ?? prev.confirmedSeq;
4294
4388
  } else {
4295
4389
  delete next.offlineTail;
4296
4390
  delete next.offlineTailEpoch;
4391
+ delete next.offlineBaseSeq;
4297
4392
  }
4298
4393
  await store.put(next);
4299
4394
  }).catch((err) => {
@@ -4325,7 +4420,13 @@ function useCollab(opts) {
4325
4420
  setRenderVersion((v) => v + 1);
4326
4421
  }
4327
4422
  const tail = offlineTailRef.current ?? [];
4328
- tail.push(intent);
4423
+ const next = intent;
4424
+ const last = tail[tail.length - 1];
4425
+ if (preApplied && last?.kind === "insertText" && next.kind === "insertText" && !last.suggest && !next.suggest && last.at.blockId === next.at.blockId && last.at.runId === next.at.runId && next.at.offset === last.at.offset + last.text.length) {
4426
+ tail[tail.length - 1] = { ...last, text: last.text + next.text };
4427
+ } else {
4428
+ tail.push(next);
4429
+ }
4329
4430
  offlineTailRef.current = tail;
4330
4431
  offlineEpochRef.current ?? (offlineEpochRef.current = lastGenesisRef.current);
4331
4432
  setOfflineHeld(tail.length);
@@ -4333,11 +4434,12 @@ function useCollab(opts) {
4333
4434
  if (connRef.current?.ready) persisterRef.current?.notify();
4334
4435
  else scheduleOfflinePersist();
4335
4436
  };
4437
+ captureOfflineRef.current = captureOffline;
4336
4438
  const runTailReplay = (conn, kind) => {
4337
4439
  const author = profile?.name || clientId;
4338
4440
  const date = (/* @__PURE__ */ new Date()).toISOString();
4339
4441
  const step = () => {
4340
- if (connRef.current !== conn || !conn.ready) return;
4442
+ if (connRef.current !== conn || !conn.ready || manuallyOfflineRef.current) return;
4341
4443
  if (conn.pendingCount > 0) {
4342
4444
  setTimeout(step, 25);
4343
4445
  return;
@@ -4348,6 +4450,9 @@ function useCollab(opts) {
4348
4450
  offlineEpochRef.current = null;
4349
4451
  setOfflineHeld(0);
4350
4452
  setArrival(null);
4453
+ disconnectSeqRef.current = null;
4454
+ manualDraftBankedRef.current = false;
4455
+ manualDraftSnapshotRef.current = null;
4351
4456
  return;
4352
4457
  }
4353
4458
  const next = tail[0];
@@ -4366,7 +4471,53 @@ function useCollab(opts) {
4366
4471
  };
4367
4472
  useEffect(() => {
4368
4473
  clearDeadlines();
4474
+ if (startOffline && reconnectNonce === 0) {
4475
+ let disposed2 = false;
4476
+ if (!store) {
4477
+ setRefused("no-session");
4478
+ return;
4479
+ }
4480
+ void store.get(docId).then((bundle) => {
4481
+ if (disposed2) return;
4482
+ if (!bundle) {
4483
+ setRefused("no-session");
4484
+ return;
4485
+ }
4486
+ const replica = new ClientReplica(bundle.confirmedBytes, bundle.confirmedSidecar);
4487
+ for (const intent of bundle.pending) replica.submitLocal(intent);
4488
+ const offlineTail = bundle.offlineTail ?? [];
4489
+ let scope = null;
4490
+ for (const intent of offlineTail) {
4491
+ const result = applyIntentScoped(replica.doc, replica.ids, intent);
4492
+ if (!result.applied) continue;
4493
+ resyncScope(replica.doc, replica.ids, result);
4494
+ scope = scope ? unionScopes(scope, result) : result;
4495
+ }
4496
+ offlineBaseBundleRef.current = bundle;
4497
+ offlineTailRef.current = offlineTail.length ? [...offlineTail] : void 0;
4498
+ offlineEpochRef.current = bundle.offlineTailEpoch ?? bundle.genesisId;
4499
+ disconnectSeqRef.current = bundle.offlineBaseSeq ?? bundle.confirmedSeq;
4500
+ lastGenesisRef.current = bundle.genesisId;
4501
+ docRef.current = replica.doc;
4502
+ publishedDocRef.current = replica.doc;
4503
+ renderScopeRef.current = scope ?? { kind: "doc" };
4504
+ setDoc(replica.doc);
4505
+ setOfflineHeld(offlineTail.length);
4506
+ setReady(true);
4507
+ setVersion((value) => value + 1);
4508
+ setRenderVersion((value) => value + 1);
4509
+ connectionRef.current = "lost";
4510
+ setConnection("lost");
4511
+ }).catch(() => {
4512
+ if (!disposed2) setPersistErrors((count) => count + 1);
4513
+ });
4514
+ return () => {
4515
+ disposed2 = true;
4516
+ flushOfflinePersist();
4517
+ };
4518
+ }
4369
4519
  const socket = (createSocket ?? ((u) => new WebSocket(u)))(url);
4520
+ activeSocketRef.current = socket;
4370
4521
  let disposed = false;
4371
4522
  let dropHandled = false;
4372
4523
  let monitor = null;
@@ -4374,16 +4525,23 @@ function useCollab(opts) {
4374
4525
  if (dropHandled || disposed) return;
4375
4526
  dropHandled = true;
4376
4527
  monitor?.stop();
4528
+ if (manuallyOfflineRef.current) {
4529
+ connectionRef.current = "lost";
4530
+ setConnection("lost");
4531
+ return;
4532
+ }
4377
4533
  try {
4378
4534
  socket.close?.();
4379
4535
  } catch {
4380
4536
  }
4381
4537
  if (attemptRef.current >= maxRetries) {
4538
+ connectionRef.current = "lost";
4382
4539
  setConnection("lost");
4383
4540
  return;
4384
4541
  }
4385
4542
  attemptRef.current += 1;
4386
4543
  backoffRef.current = Math.min(backoffRef.current === 0 ? 300 : backoffRef.current * 2, 5e3);
4544
+ connectionRef.current = "reconnecting";
4387
4545
  setConnection("reconnecting");
4388
4546
  retryTimerRef.current = setTimeout(() => {
4389
4547
  retryTimerRef.current = null;
@@ -4410,7 +4568,12 @@ function useCollab(opts) {
4410
4568
  onChange: () => {
4411
4569
  const c = connRef.current;
4412
4570
  if (!c) return;
4413
- setDoc(c.doc);
4571
+ const nextDoc = c.doc;
4572
+ const objectChanged = publishedDocRef.current !== null && nextDoc !== publishedDocRef.current;
4573
+ const connectionEpochChanged = c.docEpoch !== connectionDocEpochRef.current;
4574
+ publishedDocRef.current = nextDoc;
4575
+ connectionDocEpochRef.current = c.docEpoch;
4576
+ setDoc(nextDoc);
4414
4577
  setReady(c.ready);
4415
4578
  setVersion((v) => v + 1);
4416
4579
  const dv = c.docVersion;
@@ -4418,24 +4581,43 @@ function useCollab(opts) {
4418
4581
  lastDocVersionRef.current = dv;
4419
4582
  setRenderVersion((v) => v + 1);
4420
4583
  }
4421
- setDocEpoch(c.docEpoch);
4584
+ if (objectChanged || connectionEpochChanged) setDocEpoch((epoch) => epoch + 1);
4422
4585
  persister?.notify();
4423
- if (c.ready) {
4586
+ if (c.ready && !manuallyOfflineRef.current) {
4424
4587
  backoffRef.current = 0;
4425
4588
  attemptRef.current = 0;
4589
+ connectionRef.current = "live";
4426
4590
  setConnection((s) => s === "live" ? s : "live");
4427
4591
  lastGenesisRef.current = c.genesisId;
4428
4592
  if (tailConnRef.current !== c) {
4429
4593
  tailConnRef.current = c;
4430
4594
  const tail = offlineTailRef.current ?? [];
4431
4595
  if (tail.length) {
4432
- if (offlineEpochRef.current === c.genesisId) {
4433
- runTailReplay(c, "plain");
4596
+ const confirmedSeq = c.exportBundle(docId)?.confirmedSeq ?? null;
4597
+ const roomChangedWhileAway = disconnectSeqRef.current !== null && confirmedSeq !== null && confirmedSeq > disconnectSeqRef.current;
4598
+ if (offlineEpochRef.current === c.genesisId && !roomChangedWhileAway) {
4599
+ if (disconnectSeqRef.current !== null) {
4600
+ setArrival({ mode: "replay", tailLength: tail.length, structural: 0 });
4601
+ } else {
4602
+ runTailReplay(c, "plain");
4603
+ }
4434
4604
  } else {
4435
4605
  const structural = toSuggestions(tail, "", "").dropped.length;
4436
4606
  const mode = arrivalMode(tail.length, true) === "draft" ? "draft" : "suggest";
4437
4607
  setArrival({ mode, tailLength: tail.length, structural });
4608
+ if (roomChangedWhileAway && store && !manualDraftBankedRef.current) {
4609
+ const epoch = offlineEpochRef.current ?? c.genesisId;
4610
+ const snapshot = manualDraftSnapshotRef.current;
4611
+ if (epoch && snapshot) {
4612
+ manualDraftBankedRef.current = true;
4613
+ void store.put({ ...snapshot, docId: draftKey(docId, epoch) });
4614
+ }
4615
+ }
4438
4616
  }
4617
+ } else {
4618
+ disconnectSeqRef.current = null;
4619
+ manualDraftBankedRef.current = false;
4620
+ manualDraftSnapshotRef.current = null;
4439
4621
  }
4440
4622
  }
4441
4623
  }
@@ -4515,7 +4697,7 @@ function useCollab(opts) {
4515
4697
  // ERASE a stored tail the user had not reconciled yet.
4516
4698
  offlineTail: () => {
4517
4699
  const tail = offlineTailRef.current ?? [];
4518
- return tail.length ? { tail: [...tail], epoch: offlineEpochRef.current ?? void 0 } : null;
4700
+ return tail.length ? { tail: [...tail], epoch: offlineEpochRef.current ?? void 0, baseSeq: disconnectSeqRef.current ?? void 0 } : null;
4519
4701
  }
4520
4702
  });
4521
4703
  persisterRef.current = persister;
@@ -4537,6 +4719,7 @@ function useCollab(opts) {
4537
4719
  if (!offlineTailRef.current?.length) {
4538
4720
  offlineTailRef.current = bundle?.offlineTail;
4539
4721
  offlineEpochRef.current = bundle?.offlineTailEpoch ?? null;
4722
+ disconnectSeqRef.current = bundle?.offlineTail?.length && !bundle.pending.length ? bundle.offlineBaseSeq ?? bundle.confirmedSeq : null;
4540
4723
  setOfflineHeld(bundle?.offlineTail?.length ?? 0);
4541
4724
  }
4542
4725
  if (bundle) conn.resume(bundle, token, { profile, codeProof, ownerToken });
@@ -4559,8 +4742,9 @@ function useCollab(opts) {
4559
4742
  persister?.stop();
4560
4743
  if (persisterRef.current === persister) persisterRef.current = null;
4561
4744
  connRef.current = null;
4745
+ if (activeSocketRef.current === socket) activeSocketRef.current = null;
4562
4746
  };
4563
- }, [url, docId, clientId, token, createSocket, store, takeover, docKey, shareCode, ownerToken, httpBase, reconnectNonce]);
4747
+ }, [url, docId, clientId, token, createSocket, store, takeover, docKey, shareCode, ownerToken, httpBase, reconnectNonce, startOffline]);
4564
4748
  useEffect(() => () => {
4565
4749
  if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current);
4566
4750
  retryTimerRef.current = null;
@@ -4569,6 +4753,9 @@ function useCollab(opts) {
4569
4753
  const refusalBlocked = refused !== null || (myWrite !== void 0 ? myWrite !== "allowed" : readOnlyBlocked);
4570
4754
  const offlineActive = !refusalBlocked && connection !== "live" && ready;
4571
4755
  const offlineCapped = offlineActive && offlineHeld >= (opts.offlineTailCap ?? OFFLINE_TAIL_CAP);
4756
+ docRef.current = doc;
4757
+ offlineActiveRef.current = offlineActive;
4758
+ offlineCappedRef.current = offlineCapped;
4572
4759
  return {
4573
4760
  doc,
4574
4761
  version,
@@ -4596,7 +4783,7 @@ function useCollab(opts) {
4596
4783
  // that slips to the connection anyway lands in the loud droppedPreReady
4597
4784
  // backstop rather than vanishing.
4598
4785
  submit: (intent) => {
4599
- if (offlineActive && !offlineCapped) return captureOffline(
4786
+ if (offlineActiveRef.current && !offlineCappedRef.current) return captureOfflineRef.current(
4600
4787
  intent,
4601
4788
  /*preApplied*/
4602
4789
  true
@@ -4608,16 +4795,25 @@ function useCollab(opts) {
4608
4795
  // code before recording, so the local result stays byte-identical to
4609
4796
  // what every replica will derive when the tail replays.
4610
4797
  submitOp: (intent) => {
4611
- if (offlineActive && !offlineCapped) return captureOffline(
4798
+ if (offlineActiveRef.current && !offlineCappedRef.current) return captureOfflineRef.current(
4612
4799
  intent,
4613
4800
  /*preApplied*/
4614
4801
  false
4615
4802
  );
4616
4803
  connRef.current?.submit(intent);
4617
4804
  },
4618
- setPresence: (pos) => connRef.current?.setPresence(pos),
4619
- allocIds: (n) => connRef.current?.allocIds(n) ?? [],
4620
- uploadMedia: async (bytes) => await connRef.current?.uploadMedia(bytes) ?? null,
4805
+ setPresence: (pos) => {
4806
+ if (connectionRef.current === "live") connRef.current?.setPresence(pos);
4807
+ },
4808
+ allocIds: (n) => {
4809
+ const out = [];
4810
+ while (out.length < n) {
4811
+ const id = carriedIdAllocator.next();
4812
+ if (!docRef.current?.stableIds?.elOf(id)) out.push(id);
4813
+ }
4814
+ return out;
4815
+ },
4816
+ uploadMedia: async (bytes) => connectionRef.current === "live" ? await connRef.current?.uploadMedia(bytes) ?? null : null,
4621
4817
  // Read straight off the connection rather than mirrored into state: it is
4622
4818
  // set once when the welcome lands and never changes for the session, and
4623
4819
  // in the encrypted connection it is assigned OUTSIDE the serial rehydrate
@@ -4633,8 +4829,8 @@ function useCollab(opts) {
4633
4829
  // at construction — a pre-existing modelling wrinkle, not this arc's), so
4634
4830
  // the access is structural. The TYPE is the real one now, so a change to
4635
4831
  // UndoOutcome breaks here rather than silently disagreeing.
4636
- undoLast: () => connRef.current?.undoLast?.() ?? "unavailable",
4637
- presence,
4832
+ undoLast: () => connectionRef.current === "live" ? connRef.current?.undoLast?.() ?? "unavailable" : "unavailable",
4833
+ presence: connection === "live" ? presence : EMPTY_PRESENCE,
4638
4834
  refused,
4639
4835
  sessionWarning,
4640
4836
  readOnlyBlocked,
@@ -4681,12 +4877,35 @@ function useCollab(opts) {
4681
4877
  writeStatus: myWrite,
4682
4878
  connection,
4683
4879
  reconnect,
4880
+ disconnect,
4881
+ restoreOfflineView: () => {
4882
+ const current = connRef.current?.doc;
4883
+ const ids = current?.stableIds;
4884
+ const tail = offlineTailRef.current ?? [];
4885
+ if (!current || !ids || tail.length === 0) return;
4886
+ let scope = null;
4887
+ for (const intent of tail) {
4888
+ const result = applyIntentScoped(current, ids, intent);
4889
+ if (!result.applied) continue;
4890
+ resyncScope(current, ids, result);
4891
+ scope = scope ? unionScopes(scope, result) : result;
4892
+ }
4893
+ if (scope) {
4894
+ renderScopeRef.current = scope;
4895
+ setVersion((value) => value + 1);
4896
+ setRenderVersion((value) => value + 1);
4897
+ }
4898
+ },
4684
4899
  retryWrites: () => setReadOnlyBlocked(false),
4685
4900
  epochChanged,
4686
4901
  roster,
4687
- setProfile: (p) => connRef.current?.setProfile(p),
4902
+ setProfile: (p) => {
4903
+ if (connectionRef.current === "live") connRef.current?.setProfile(p);
4904
+ },
4688
4905
  activity: connRef.current?.activity ?? [],
4689
- admin: (action) => connRef.current?.admin(action),
4906
+ admin: (action) => {
4907
+ if (connectionRef.current === "live") connRef.current?.admin(action);
4908
+ },
4690
4909
  selfHeals,
4691
4910
  droppedPreReady,
4692
4911
  persistErrors,
@@ -4701,15 +4920,23 @@ function useCollab(opts) {
4701
4920
  offlineEpochRef.current = null;
4702
4921
  setOfflineHeld(0);
4703
4922
  setArrival(null);
4923
+ disconnectSeqRef.current = null;
4924
+ manualDraftBankedRef.current = false;
4925
+ manualDraftSnapshotRef.current = null;
4704
4926
  return;
4705
4927
  }
4706
4928
  setArrival(null);
4707
- if (mode === "suggest") {
4929
+ if (mode === "replay") {
4930
+ runTailReplay(conn, "plain");
4931
+ } else if (mode === "suggest") {
4708
4932
  runTailReplay(conn, "suggest");
4709
4933
  } else {
4710
4934
  offlineTailRef.current = void 0;
4711
4935
  offlineEpochRef.current = null;
4712
4936
  setOfflineHeld(0);
4937
+ disconnectSeqRef.current = null;
4938
+ manualDraftBankedRef.current = false;
4939
+ manualDraftSnapshotRef.current = null;
4713
4940
  void persisterRef.current?.flush();
4714
4941
  }
4715
4942
  }
@@ -4735,7 +4962,7 @@ function CollabEditor(opts) {
4735
4962
  const session = useCollab(opts);
4736
4963
  useEffect(() => {
4737
4964
  opts.onSession?.(session);
4738
- }, [session.version, session.ready, session.refused, session.roster, session.epochChanged, session.sessionWarning, session.writesBlocked, session.connection, session.persistErrors]);
4965
+ }, [session.version, session.ready, session.refused, session.roster, session.epochChanged, session.sessionWarning, session.writesBlocked, session.connection, session.persistErrors, session.storeSlow, session.notOwner, session.arrival, session.offline?.editsHeld, session.offline?.capped]);
4739
4966
  const [api, setApi] = useState(null);
4740
4967
  const pendingCaretRef = useRef(null);
4741
4968
  const seenEpochRef = useRef(session.docEpoch);
@@ -4755,7 +4982,7 @@ function CollabEditor(opts) {
4755
4982
  // Legible over a rendered page in the overlay case; inert extra
4756
4983
  // styling on the plain full-screen path costs nothing.
4757
4984
  docVisible ? { style: { margin: 16, padding: "10px 14px", width: "fit-content", background: "rgba(255,255,255,.95)", borderRadius: 8, font: "14px system-ui", boxShadow: "0 2px 8px rgba(0,0,0,.25)" } } : void 0,
4758
- `Please refresh \u2014 ${session.refused}.`
4985
+ `Server response: ${session.refused}. Reload the page.`
4759
4986
  );
4760
4987
  if (!docVisible) {
4761
4988
  return createElement("div", { className: "dxw-collab-refused" }, content);
@@ -4850,8 +5077,9 @@ function CollabEditor(opts) {
4850
5077
  // and lost it, which is why the read-only banner could be on screen while
4851
5078
  // the user's text appeared and then vanished.
4852
5079
  editable: (opts.editable ?? true) && !session.writesBlocked,
4853
- // Re-key only on docEpoch (a true-conflict reload) — NOT on every change.
4854
- // Between reloads the live doc mutates in place and the key stays stable,
5080
+ revisions: opts.revisions,
5081
+ // Re-key only on docEpoch (a document replacement), not on every change.
5082
+ // Between replacements the live doc mutates in place and the key stays stable,
4855
5083
  // so DocxView repaints in place instead of re-mounting (no flash/jump).
4856
5084
  key: session.docEpoch
4857
5085
  });
@@ -4883,7 +5111,7 @@ function ConnectingNotice() {
4883
5111
  "aria-busy": "true"
4884
5112
  },
4885
5113
  createElement("span", { className: "dxw-collab-spinner", "aria-hidden": "true", key: "s" }),
4886
- slow ? "Still connecting \u2014 is the collab server running? (start it, then reload this page)" : "Connecting\u2026"
5114
+ slow ? "Still connecting. Check your connection, then reload this page." : "Connecting\u2026"
4887
5115
  );
4888
5116
  }
4889
5117
  export {