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/{chunk-JGXXZTQE.js → chunk-OIN65MGS.js} +91 -70
- package/dist/chunk-OIN65MGS.js.map +1 -0
- package/dist/collab.d.ts +35 -19
- package/dist/collab.js +231 -50
- 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-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
|
|
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 = "
|
|
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 =
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4433
|
-
|
|
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 (
|
|
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 (
|
|
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) =>
|
|
4619
|
-
|
|
4620
|
-
|
|
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) =>
|
|
4855
|
+
setProfile: (p) => {
|
|
4856
|
+
if (connectionRef.current === "live") connRef.current?.setProfile(p);
|
|
4857
|
+
},
|
|
4688
4858
|
activity: connRef.current?.activity ?? [],
|
|
4689
|
-
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 === "
|
|
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
|
-
`
|
|
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
|
-
|
|
4854
|
-
//
|
|
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
|
|
5067
|
+
slow ? "Still connecting. Check your connection, then reload this page." : "Connecting\u2026"
|
|
4887
5068
|
);
|
|
4888
5069
|
}
|
|
4889
5070
|
export {
|