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/{chunk-JGXXZTQE.js → chunk-QH2L3NPL.js} +163 -75
- package/dist/chunk-QH2L3NPL.js.map +1 -0
- package/dist/collab.d.ts +38 -19
- package/dist/collab.js +281 -53
- package/dist/collab.js.map +1 -1
- package/dist/{index-C2J9egkh.d.ts → index-CGp6gVjn.d.ts} +29 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-JGXXZTQE.js.map +0 -1
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-
|
|
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
|
|
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)
|
|
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 = "
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4433
|
-
|
|
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 (
|
|
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 (
|
|
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) =>
|
|
4619
|
-
|
|
4620
|
-
|
|
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) =>
|
|
4902
|
+
setProfile: (p) => {
|
|
4903
|
+
if (connectionRef.current === "live") connRef.current?.setProfile(p);
|
|
4904
|
+
},
|
|
4688
4905
|
activity: connRef.current?.activity ?? [],
|
|
4689
|
-
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 === "
|
|
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
|
-
`
|
|
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
|
-
|
|
4854
|
-
//
|
|
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
|
|
5114
|
+
slow ? "Still connecting. Check your connection, then reload this page." : "Connecting\u2026"
|
|
4887
5115
|
);
|
|
4888
5116
|
}
|
|
4889
5117
|
export {
|