wordinweb 0.2.2 → 0.2.3

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-OIN65MGS.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;
@@ -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 = "e9";
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) {
@@ -4230,12 +4275,20 @@ function withDeadline(p, ms, fallback) {
4230
4275
  function useCollab(opts) {
4231
4276
  const { url, docId, clientId, token, createSocket, store, profile, takeover, docKey, shareCode, ownerToken, httpBase } = opts;
4232
4277
  const connRef = useRef(null);
4278
+ const carriedIdAllocator = useMemo(() => new CarriedIdAllocator(clientId), [clientId]);
4233
4279
  const [doc, setDoc] = useState(null);
4280
+ const docRef = useRef(null);
4281
+ const offlineActiveRef = useRef(false);
4282
+ const offlineCappedRef = useRef(false);
4283
+ const captureOfflineRef = useRef(() => {
4284
+ });
4234
4285
  const [version, setVersion] = useState(0);
4235
4286
  const [renderVersion, setRenderVersion] = useState(0);
4236
4287
  const lastDocVersionRef = useRef(0);
4237
4288
  const renderScopeRef = useRef(null);
4238
4289
  const [docEpoch, setDocEpoch] = useState(0);
4290
+ const publishedDocRef = useRef(null);
4291
+ const connectionDocEpochRef = useRef(0);
4239
4292
  const [ready, setReady] = useState(false);
4240
4293
  const [presence, setPresence] = useState({});
4241
4294
  const [refused, setRefused] = useState(null);
@@ -4265,10 +4318,16 @@ function useCollab(opts) {
4265
4318
  const tailConnRef = useRef(null);
4266
4319
  const offlinePersistTimerRef = useRef(null);
4267
4320
  const [connection, setConnection] = useState("live");
4321
+ const connectionRef = useRef("live");
4268
4322
  const [reconnectNonce, setReconnectNonce] = useState(0);
4269
4323
  const backoffRef = useRef(0);
4270
4324
  const attemptRef = useRef(0);
4271
4325
  const retryTimerRef = useRef(null);
4326
+ const activeSocketRef = useRef(null);
4327
+ const manuallyOfflineRef = useRef(false);
4328
+ const disconnectSeqRef = useRef(null);
4329
+ const manualDraftBankedRef = useRef(false);
4330
+ const manualDraftSnapshotRef = useRef(null);
4272
4331
  const flushDoneRef = useRef(Promise.resolve());
4273
4332
  const maxRetries = opts.liveness?.maxRetries ?? 6;
4274
4333
  const livenessRef = useRef(opts.liveness);
@@ -4278,9 +4337,41 @@ function useCollab(opts) {
4278
4337
  retryTimerRef.current = null;
4279
4338
  backoffRef.current = 0;
4280
4339
  attemptRef.current = 0;
4340
+ if (manuallyOfflineRef.current) {
4341
+ const currentDoc = docRef.current;
4342
+ const currentBundle = connRef.current?.exportBundle(docId);
4343
+ const ids = currentDoc?.stableIds;
4344
+ manualDraftSnapshotRef.current = currentDoc && currentBundle && ids ? {
4345
+ ...currentBundle,
4346
+ confirmedBytes: currentDoc.save(),
4347
+ confirmedSidecar: ids.exportSidecar(currentDoc.editableRoots()),
4348
+ pending: [],
4349
+ offlineTail: void 0,
4350
+ offlineTailEpoch: void 0,
4351
+ offlineBaseSeq: void 0,
4352
+ savedAt: Date.now()
4353
+ } : null;
4354
+ }
4355
+ manuallyOfflineRef.current = false;
4356
+ connectionRef.current = "reconnecting";
4281
4357
  setConnection("reconnecting");
4282
4358
  setReconnectNonce((n) => n + 1);
4283
4359
  };
4360
+ const disconnect = () => {
4361
+ if (manuallyOfflineRef.current) return;
4362
+ disconnectSeqRef.current = connRef.current?.exportBundle(docId)?.confirmedSeq ?? null;
4363
+ manualDraftBankedRef.current = false;
4364
+ manualDraftSnapshotRef.current = null;
4365
+ manuallyOfflineRef.current = true;
4366
+ if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current);
4367
+ retryTimerRef.current = null;
4368
+ connectionRef.current = "lost";
4369
+ setConnection("lost");
4370
+ try {
4371
+ activeSocketRef.current?.close();
4372
+ } catch {
4373
+ }
4374
+ };
4284
4375
  const persistOfflineTail = () => {
4285
4376
  if (!store) return;
4286
4377
  flushDoneRef.current = flushDoneRef.current.then(async () => {
@@ -4291,9 +4382,11 @@ function useCollab(opts) {
4291
4382
  if (tail.length) {
4292
4383
  next.offlineTail = [...tail];
4293
4384
  next.offlineTailEpoch = offlineEpochRef.current ?? void 0;
4385
+ next.offlineBaseSeq = disconnectSeqRef.current ?? prev.confirmedSeq;
4294
4386
  } else {
4295
4387
  delete next.offlineTail;
4296
4388
  delete next.offlineTailEpoch;
4389
+ delete next.offlineBaseSeq;
4297
4390
  }
4298
4391
  await store.put(next);
4299
4392
  }).catch((err) => {
@@ -4325,7 +4418,13 @@ function useCollab(opts) {
4325
4418
  setRenderVersion((v) => v + 1);
4326
4419
  }
4327
4420
  const tail = offlineTailRef.current ?? [];
4328
- tail.push(intent);
4421
+ const next = intent;
4422
+ const last = tail[tail.length - 1];
4423
+ 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) {
4424
+ tail[tail.length - 1] = { ...last, text: last.text + next.text };
4425
+ } else {
4426
+ tail.push(next);
4427
+ }
4329
4428
  offlineTailRef.current = tail;
4330
4429
  offlineEpochRef.current ?? (offlineEpochRef.current = lastGenesisRef.current);
4331
4430
  setOfflineHeld(tail.length);
@@ -4333,11 +4432,12 @@ function useCollab(opts) {
4333
4432
  if (connRef.current?.ready) persisterRef.current?.notify();
4334
4433
  else scheduleOfflinePersist();
4335
4434
  };
4435
+ captureOfflineRef.current = captureOffline;
4336
4436
  const runTailReplay = (conn, kind) => {
4337
4437
  const author = profile?.name || clientId;
4338
4438
  const date = (/* @__PURE__ */ new Date()).toISOString();
4339
4439
  const step = () => {
4340
- if (connRef.current !== conn || !conn.ready) return;
4440
+ if (connRef.current !== conn || !conn.ready || manuallyOfflineRef.current) return;
4341
4441
  if (conn.pendingCount > 0) {
4342
4442
  setTimeout(step, 25);
4343
4443
  return;
@@ -4348,6 +4448,9 @@ function useCollab(opts) {
4348
4448
  offlineEpochRef.current = null;
4349
4449
  setOfflineHeld(0);
4350
4450
  setArrival(null);
4451
+ disconnectSeqRef.current = null;
4452
+ manualDraftBankedRef.current = false;
4453
+ manualDraftSnapshotRef.current = null;
4351
4454
  return;
4352
4455
  }
4353
4456
  const next = tail[0];
@@ -4367,6 +4470,7 @@ function useCollab(opts) {
4367
4470
  useEffect(() => {
4368
4471
  clearDeadlines();
4369
4472
  const socket = (createSocket ?? ((u) => new WebSocket(u)))(url);
4473
+ activeSocketRef.current = socket;
4370
4474
  let disposed = false;
4371
4475
  let dropHandled = false;
4372
4476
  let monitor = null;
@@ -4374,16 +4478,23 @@ function useCollab(opts) {
4374
4478
  if (dropHandled || disposed) return;
4375
4479
  dropHandled = true;
4376
4480
  monitor?.stop();
4481
+ if (manuallyOfflineRef.current) {
4482
+ connectionRef.current = "lost";
4483
+ setConnection("lost");
4484
+ return;
4485
+ }
4377
4486
  try {
4378
4487
  socket.close?.();
4379
4488
  } catch {
4380
4489
  }
4381
4490
  if (attemptRef.current >= maxRetries) {
4491
+ connectionRef.current = "lost";
4382
4492
  setConnection("lost");
4383
4493
  return;
4384
4494
  }
4385
4495
  attemptRef.current += 1;
4386
4496
  backoffRef.current = Math.min(backoffRef.current === 0 ? 300 : backoffRef.current * 2, 5e3);
4497
+ connectionRef.current = "reconnecting";
4387
4498
  setConnection("reconnecting");
4388
4499
  retryTimerRef.current = setTimeout(() => {
4389
4500
  retryTimerRef.current = null;
@@ -4410,7 +4521,12 @@ function useCollab(opts) {
4410
4521
  onChange: () => {
4411
4522
  const c = connRef.current;
4412
4523
  if (!c) return;
4413
- setDoc(c.doc);
4524
+ const nextDoc = c.doc;
4525
+ const objectChanged = publishedDocRef.current !== null && nextDoc !== publishedDocRef.current;
4526
+ const connectionEpochChanged = c.docEpoch !== connectionDocEpochRef.current;
4527
+ publishedDocRef.current = nextDoc;
4528
+ connectionDocEpochRef.current = c.docEpoch;
4529
+ setDoc(nextDoc);
4414
4530
  setReady(c.ready);
4415
4531
  setVersion((v) => v + 1);
4416
4532
  const dv = c.docVersion;
@@ -4418,24 +4534,43 @@ function useCollab(opts) {
4418
4534
  lastDocVersionRef.current = dv;
4419
4535
  setRenderVersion((v) => v + 1);
4420
4536
  }
4421
- setDocEpoch(c.docEpoch);
4537
+ if (objectChanged || connectionEpochChanged) setDocEpoch((epoch) => epoch + 1);
4422
4538
  persister?.notify();
4423
- if (c.ready) {
4539
+ if (c.ready && !manuallyOfflineRef.current) {
4424
4540
  backoffRef.current = 0;
4425
4541
  attemptRef.current = 0;
4542
+ connectionRef.current = "live";
4426
4543
  setConnection((s) => s === "live" ? s : "live");
4427
4544
  lastGenesisRef.current = c.genesisId;
4428
4545
  if (tailConnRef.current !== c) {
4429
4546
  tailConnRef.current = c;
4430
4547
  const tail = offlineTailRef.current ?? [];
4431
4548
  if (tail.length) {
4432
- if (offlineEpochRef.current === c.genesisId) {
4433
- runTailReplay(c, "plain");
4549
+ const confirmedSeq = c.exportBundle(docId)?.confirmedSeq ?? null;
4550
+ const roomChangedWhileAway = disconnectSeqRef.current !== null && confirmedSeq !== null && confirmedSeq > disconnectSeqRef.current;
4551
+ if (offlineEpochRef.current === c.genesisId && !roomChangedWhileAway) {
4552
+ if (disconnectSeqRef.current !== null) {
4553
+ setArrival({ mode: "replay", tailLength: tail.length, structural: 0 });
4554
+ } else {
4555
+ runTailReplay(c, "plain");
4556
+ }
4434
4557
  } else {
4435
4558
  const structural = toSuggestions(tail, "", "").dropped.length;
4436
4559
  const mode = arrivalMode(tail.length, true) === "draft" ? "draft" : "suggest";
4437
4560
  setArrival({ mode, tailLength: tail.length, structural });
4561
+ if (roomChangedWhileAway && store && !manualDraftBankedRef.current) {
4562
+ const epoch = offlineEpochRef.current ?? c.genesisId;
4563
+ const snapshot = manualDraftSnapshotRef.current;
4564
+ if (epoch && snapshot) {
4565
+ manualDraftBankedRef.current = true;
4566
+ void store.put({ ...snapshot, docId: draftKey(docId, epoch) });
4567
+ }
4568
+ }
4438
4569
  }
4570
+ } else {
4571
+ disconnectSeqRef.current = null;
4572
+ manualDraftBankedRef.current = false;
4573
+ manualDraftSnapshotRef.current = null;
4439
4574
  }
4440
4575
  }
4441
4576
  }
@@ -4515,7 +4650,7 @@ function useCollab(opts) {
4515
4650
  // ERASE a stored tail the user had not reconciled yet.
4516
4651
  offlineTail: () => {
4517
4652
  const tail = offlineTailRef.current ?? [];
4518
- return tail.length ? { tail: [...tail], epoch: offlineEpochRef.current ?? void 0 } : null;
4653
+ return tail.length ? { tail: [...tail], epoch: offlineEpochRef.current ?? void 0, baseSeq: disconnectSeqRef.current ?? void 0 } : null;
4519
4654
  }
4520
4655
  });
4521
4656
  persisterRef.current = persister;
@@ -4537,6 +4672,7 @@ function useCollab(opts) {
4537
4672
  if (!offlineTailRef.current?.length) {
4538
4673
  offlineTailRef.current = bundle?.offlineTail;
4539
4674
  offlineEpochRef.current = bundle?.offlineTailEpoch ?? null;
4675
+ disconnectSeqRef.current = bundle?.offlineTail?.length && !bundle.pending.length ? bundle.offlineBaseSeq ?? bundle.confirmedSeq : null;
4540
4676
  setOfflineHeld(bundle?.offlineTail?.length ?? 0);
4541
4677
  }
4542
4678
  if (bundle) conn.resume(bundle, token, { profile, codeProof, ownerToken });
@@ -4559,6 +4695,7 @@ function useCollab(opts) {
4559
4695
  persister?.stop();
4560
4696
  if (persisterRef.current === persister) persisterRef.current = null;
4561
4697
  connRef.current = null;
4698
+ if (activeSocketRef.current === socket) activeSocketRef.current = null;
4562
4699
  };
4563
4700
  }, [url, docId, clientId, token, createSocket, store, takeover, docKey, shareCode, ownerToken, httpBase, reconnectNonce]);
4564
4701
  useEffect(() => () => {
@@ -4569,6 +4706,9 @@ function useCollab(opts) {
4569
4706
  const refusalBlocked = refused !== null || (myWrite !== void 0 ? myWrite !== "allowed" : readOnlyBlocked);
4570
4707
  const offlineActive = !refusalBlocked && connection !== "live" && ready;
4571
4708
  const offlineCapped = offlineActive && offlineHeld >= (opts.offlineTailCap ?? OFFLINE_TAIL_CAP);
4709
+ docRef.current = doc;
4710
+ offlineActiveRef.current = offlineActive;
4711
+ offlineCappedRef.current = offlineCapped;
4572
4712
  return {
4573
4713
  doc,
4574
4714
  version,
@@ -4596,7 +4736,7 @@ function useCollab(opts) {
4596
4736
  // that slips to the connection anyway lands in the loud droppedPreReady
4597
4737
  // backstop rather than vanishing.
4598
4738
  submit: (intent) => {
4599
- if (offlineActive && !offlineCapped) return captureOffline(
4739
+ if (offlineActiveRef.current && !offlineCappedRef.current) return captureOfflineRef.current(
4600
4740
  intent,
4601
4741
  /*preApplied*/
4602
4742
  true
@@ -4608,16 +4748,25 @@ function useCollab(opts) {
4608
4748
  // code before recording, so the local result stays byte-identical to
4609
4749
  // what every replica will derive when the tail replays.
4610
4750
  submitOp: (intent) => {
4611
- if (offlineActive && !offlineCapped) return captureOffline(
4751
+ if (offlineActiveRef.current && !offlineCappedRef.current) return captureOfflineRef.current(
4612
4752
  intent,
4613
4753
  /*preApplied*/
4614
4754
  false
4615
4755
  );
4616
4756
  connRef.current?.submit(intent);
4617
4757
  },
4618
- setPresence: (pos) => connRef.current?.setPresence(pos),
4619
- allocIds: (n) => connRef.current?.allocIds(n) ?? [],
4620
- uploadMedia: async (bytes) => await connRef.current?.uploadMedia(bytes) ?? null,
4758
+ setPresence: (pos) => {
4759
+ if (connectionRef.current === "live") connRef.current?.setPresence(pos);
4760
+ },
4761
+ allocIds: (n) => {
4762
+ const out = [];
4763
+ while (out.length < n) {
4764
+ const id = carriedIdAllocator.next();
4765
+ if (!docRef.current?.stableIds?.elOf(id)) out.push(id);
4766
+ }
4767
+ return out;
4768
+ },
4769
+ uploadMedia: async (bytes) => connectionRef.current === "live" ? await connRef.current?.uploadMedia(bytes) ?? null : null,
4621
4770
  // Read straight off the connection rather than mirrored into state: it is
4622
4771
  // set once when the welcome lands and never changes for the session, and
4623
4772
  // in the encrypted connection it is assigned OUTSIDE the serial rehydrate
@@ -4633,8 +4782,8 @@ function useCollab(opts) {
4633
4782
  // at construction — a pre-existing modelling wrinkle, not this arc's), so
4634
4783
  // the access is structural. The TYPE is the real one now, so a change to
4635
4784
  // UndoOutcome breaks here rather than silently disagreeing.
4636
- undoLast: () => connRef.current?.undoLast?.() ?? "unavailable",
4637
- presence,
4785
+ undoLast: () => connectionRef.current === "live" ? connRef.current?.undoLast?.() ?? "unavailable" : "unavailable",
4786
+ presence: connection === "live" ? presence : EMPTY_PRESENCE,
4638
4787
  refused,
4639
4788
  sessionWarning,
4640
4789
  readOnlyBlocked,
@@ -4681,12 +4830,35 @@ function useCollab(opts) {
4681
4830
  writeStatus: myWrite,
4682
4831
  connection,
4683
4832
  reconnect,
4833
+ disconnect,
4834
+ restoreOfflineView: () => {
4835
+ const current = connRef.current?.doc;
4836
+ const ids = current?.stableIds;
4837
+ const tail = offlineTailRef.current ?? [];
4838
+ if (!current || !ids || tail.length === 0) return;
4839
+ let scope = null;
4840
+ for (const intent of tail) {
4841
+ const result = applyIntentScoped(current, ids, intent);
4842
+ if (!result.applied) continue;
4843
+ resyncScope(current, ids, result);
4844
+ scope = scope ? unionScopes(scope, result) : result;
4845
+ }
4846
+ if (scope) {
4847
+ renderScopeRef.current = scope;
4848
+ setVersion((value) => value + 1);
4849
+ setRenderVersion((value) => value + 1);
4850
+ }
4851
+ },
4684
4852
  retryWrites: () => setReadOnlyBlocked(false),
4685
4853
  epochChanged,
4686
4854
  roster,
4687
- setProfile: (p) => connRef.current?.setProfile(p),
4855
+ setProfile: (p) => {
4856
+ if (connectionRef.current === "live") connRef.current?.setProfile(p);
4857
+ },
4688
4858
  activity: connRef.current?.activity ?? [],
4689
- admin: (action) => connRef.current?.admin(action),
4859
+ admin: (action) => {
4860
+ if (connectionRef.current === "live") connRef.current?.admin(action);
4861
+ },
4690
4862
  selfHeals,
4691
4863
  droppedPreReady,
4692
4864
  persistErrors,
@@ -4701,15 +4873,23 @@ function useCollab(opts) {
4701
4873
  offlineEpochRef.current = null;
4702
4874
  setOfflineHeld(0);
4703
4875
  setArrival(null);
4876
+ disconnectSeqRef.current = null;
4877
+ manualDraftBankedRef.current = false;
4878
+ manualDraftSnapshotRef.current = null;
4704
4879
  return;
4705
4880
  }
4706
4881
  setArrival(null);
4707
- if (mode === "suggest") {
4882
+ if (mode === "replay") {
4883
+ runTailReplay(conn, "plain");
4884
+ } else if (mode === "suggest") {
4708
4885
  runTailReplay(conn, "suggest");
4709
4886
  } else {
4710
4887
  offlineTailRef.current = void 0;
4711
4888
  offlineEpochRef.current = null;
4712
4889
  setOfflineHeld(0);
4890
+ disconnectSeqRef.current = null;
4891
+ manualDraftBankedRef.current = false;
4892
+ manualDraftSnapshotRef.current = null;
4713
4893
  void persisterRef.current?.flush();
4714
4894
  }
4715
4895
  }
@@ -4735,7 +4915,7 @@ function CollabEditor(opts) {
4735
4915
  const session = useCollab(opts);
4736
4916
  useEffect(() => {
4737
4917
  opts.onSession?.(session);
4738
- }, [session.version, session.ready, session.refused, session.roster, session.epochChanged, session.sessionWarning, session.writesBlocked, session.connection, session.persistErrors]);
4918
+ }, [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
4919
  const [api, setApi] = useState(null);
4740
4920
  const pendingCaretRef = useRef(null);
4741
4921
  const seenEpochRef = useRef(session.docEpoch);
@@ -4755,7 +4935,7 @@ function CollabEditor(opts) {
4755
4935
  // Legible over a rendered page in the overlay case; inert extra
4756
4936
  // styling on the plain full-screen path costs nothing.
4757
4937
  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}.`
4938
+ `Server response: ${session.refused}. Reload the page.`
4759
4939
  );
4760
4940
  if (!docVisible) {
4761
4941
  return createElement("div", { className: "dxw-collab-refused" }, content);
@@ -4850,8 +5030,9 @@ function CollabEditor(opts) {
4850
5030
  // and lost it, which is why the read-only banner could be on screen while
4851
5031
  // the user's text appeared and then vanished.
4852
5032
  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,
5033
+ revisions: opts.revisions,
5034
+ // Re-key only on docEpoch (a document replacement), not on every change.
5035
+ // Between replacements the live doc mutates in place and the key stays stable,
4855
5036
  // so DocxView repaints in place instead of re-mounting (no flash/jump).
4856
5037
  key: session.docEpoch
4857
5038
  });
@@ -4883,7 +5064,7 @@ function ConnectingNotice() {
4883
5064
  "aria-busy": "true"
4884
5065
  },
4885
5066
  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"
5067
+ slow ? "Still connecting. Check your connection, then reload this page." : "Connecting\u2026"
4887
5068
  );
4888
5069
  }
4889
5070
  export {