@panphora/clayjs 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. package/src/page/replay.js +0 -578
@@ -1,647 +0,0 @@
1
- // The page editor's live binding: one ProseMirror view on one live store, with
2
- // every text block merged by its own replay (replay.js).
3
- //
4
- // The store's value is { blocks: { <id>: block }, deleted: { <id>: true } }.
5
- // A text block is { t, pos, ...attrs, c, m? } (model.js, inline.js), and this
6
- // binding's writes add w (replay.js). Any other block is written whole and the
7
- // later write wins. A delete sets the tombstone in the same update, so a stale
8
- // write never brings the block back.
9
- //
10
- // Local side: each transaction becomes one store update with one write per
11
- // block it changed, never a ProseMirror step carried across keys. A split
12
- // truncates the first half and creates the second; a join or a delete across
13
- // blocks rewrites the first and deletes the rest.
14
- //
15
- // Remote side: each frame's ops go through the replays in room order. A block
16
- // is released into the editor (its agreed value, as minimal steps kept out of
17
- // the undo history) once this tab has no write for it in flight and is not
18
- // composing, so a release never has unconfirmed local steps under it.
19
- import { state as pmState, transform as pmTransform } from "../vendor/prosemirror.vendor.js";
20
- import { REMOTE, blockJson, docFromBlocks, newId, nodeFromBlock } from "./model.js";
21
- import { flatten, marksOf, unflatten } from "./inline.js";
22
- import { createReplay, hashOf, join, mapCaret, regions, step, strip, tombstone } from "./replay.js";
23
- import { registerUnsavedState } from "../lib/unsaved-state.js";
24
-
25
- const { TextSelection } = pmState;
26
- const rawOf = (value, id) => (value && value.blocks && value.blocks[id]) || null;
27
- const tombOf = (value, id) => !!(value && value.deleted && value.deleted[id]);
28
- const same = (a, b) => (a === null || b === null ? a === b : hashOf(a) === hashOf(b));
29
- const keyAt = (m, i) => (m && m[i]) || "";
30
- const isHigh = (u) => u >= 0xd800 && u <= 0xdbff;
31
- const isLow = (u) => u >= 0xdc00 && u <= 0xdfff;
32
-
33
- // The record's document: every block not under its tombstone.
34
- export function docFromRecord(schema, value) {
35
- const blocks = {};
36
- for (const [id, block] of Object.entries((value && value.blocks) || {})) if (!tombOf(value, id)) blocks[id] = strip(block);
37
- return docFromBlocks(schema, blocks);
38
- }
39
-
40
- // The block an op touches, or null for one on the whole map.
41
- function blockOf(op) {
42
- if ((op.path[0] === "blocks" || op.path[0] === "deleted") && op.path.length >= 2) return String(op.path[1]);
43
- return null;
44
- }
45
-
46
- // One frame's ops as block ops, in order: { id, X } (X null: deleted) or
47
- // { id, tombstone: true }. An op deeper than a block (a data API set on one
48
- // field) or on the whole map takes the block's value after the frame, whole.
49
- function blockOps(ops, prev, next) {
50
- const out = [];
51
- for (const op of ops) {
52
- const [root, id] = op.path;
53
- if (op.path.length === 0 || (op.path.length === 1 && root === "blocks")) {
54
- const ids = new Set([...Object.keys(prev?.blocks ?? {}), ...Object.keys(next?.blocks ?? {})]);
55
- for (const k of ids) out.push({ id: k, X: rawOf(next, k) ? strip(rawOf(next, k)) : null });
56
- if (op.path.length === 0) for (const k of Object.keys(next?.deleted ?? {})) out.push({ id: k, tombstone: true });
57
- continue;
58
- }
59
- if (root === "deleted") {
60
- if (op.path.length === 1 && op.op === "set" && op.value) for (const k of Object.keys(op.value)) if (op.value[k]) out.push({ id: k, tombstone: true });
61
- if (op.path.length === 2 && op.op === "set" && op.value) out.push({ id: String(id), tombstone: true });
62
- continue;
63
- }
64
- if (root !== "blocks") continue;
65
- if (op.path.length === 2) out.push({ id: String(id), X: op.op === "set" ? op.value : null });
66
- else out.push({ id: String(id), X: rawOf(next, id) ? strip(rawOf(next, id)) : null });
67
- }
68
- return out;
69
- }
70
-
71
- // Per top-level block of the final document, the range of its content every
72
- // step of these transactions touched, in that document's offsets. A step that
73
- // only changes a node's type or attrs touches no content.
74
- function touchedRanges(trs, doc) {
75
- const maps = trs.flatMap((tr) => tr.mapping.maps);
76
- const steps = trs.flatMap((tr) => tr.steps);
77
- const ranges = [];
78
- steps.forEach((s, i) => {
79
- if (s instanceof pmTransform.AttrStep) return;
80
- if (s instanceof pmTransform.ReplaceAroundStep && s.structure && s.gapFrom === s.from + 1 && s.gapTo === s.to - 1 && s.slice.content.childCount === 1 && s.slice.openStart === 0) return;
81
- const later = new pmTransform.Mapping(maps.slice(i + 1));
82
- const own = [];
83
- if (s instanceof pmTransform.AddMarkStep || s instanceof pmTransform.RemoveMarkStep) own.push([s.from, s.to]);
84
- else s.getMap().forEach((_a, _b, from, to) => own.push([from, to]));
85
- for (const [from, to] of own) ranges.push([later.map(from, -1), later.map(to, 1)]);
86
- });
87
- const out = new Map();
88
- doc.forEach((node, offset) => {
89
- if (!node.isTextblock || !node.attrs.id) return;
90
- const start = offset + 1, end = offset + node.nodeSize - 1;
91
- for (const [a, b] of ranges) {
92
- if (b < start || a > end) continue;
93
- const r = out.get(node.attrs.id) ?? { from: Infinity, to: -Infinity };
94
- out.set(node.attrs.id, { from: Math.min(r.from, Math.max(a, start) - start), to: Math.max(r.to, Math.min(b, end) - start) });
95
- }
96
- });
97
- return out;
98
- }
99
-
100
- const sameUnit = (x, i, y, j) => x.c.charCodeAt(i) === y.c.charCodeAt(j) && keyAt(x.m, i) === keyAt(y.m, j);
101
-
102
- // The splice that turns prev into next (flat { c, m }): prev[p, e) became
103
- // next[p, q). Its position comes from the steps' range when that range explains
104
- // the change; otherwise from a diff. Units equal in text and marks at either
105
- // end are trimmed off, and a surrogate pair is never cut.
106
- function spliceFrom(prev, next, range) {
107
- let p, e, q;
108
- const fits = (p, e, q) => {
109
- if (p < 0 || e < p || q < p || e > prev.c.length || q > next.c.length || prev.c.length - e !== next.c.length - q) return false;
110
- for (let i = 0; i < p; i++) if (!sameUnit(prev, i, next, i)) return false;
111
- for (let i = 0; i < prev.c.length - e; i++) if (!sameUnit(prev, e + i, next, q + i)) return false;
112
- return true;
113
- };
114
- if (range && fits(range.from, prev.c.length - (next.c.length - range.to), range.to)) {
115
- p = range.from;
116
- q = range.to;
117
- e = prev.c.length - (next.c.length - q);
118
- } else {
119
- p = 0;
120
- while (p < prev.c.length && p < next.c.length && sameUnit(prev, p, next, p)) p++;
121
- let s = 0;
122
- while (s < prev.c.length - p && s < next.c.length - p && sameUnit(prev, prev.c.length - 1 - s, next, next.c.length - 1 - s)) s++;
123
- e = prev.c.length - s;
124
- q = next.c.length - s;
125
- }
126
- while (p < e && p < q && sameUnit(prev, p, next, p)) p++;
127
- while (e > p && q > p && sameUnit(prev, e - 1, next, q - 1)) { e--; q--; }
128
- if (p > 0 && isHigh(prev.c.charCodeAt(p - 1))) p--;
129
- if (e < prev.c.length && isLow(prev.c.charCodeAt(e))) { e++; q++; }
130
- if (p === e && p === q) return null;
131
- const rm = prev.m.slice(p, e);
132
- return [p, e, prev.c.slice(p, e), prev.c.length, ...(rm.some(Boolean) ? [rm] : [])];
133
- }
134
-
135
- function changedFields(before, after) {
136
- const out = [];
137
- for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
138
- if (name === "c" || name === "m" || name === "w") continue;
139
- if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) out.push(name);
140
- }
141
- return out;
142
- }
143
-
144
- function findBlock(doc, id) {
145
- let found = null;
146
- doc.forEach((node, pos, index) => { if (!found && node.attrs.id === id) found = { node, pos, index }; });
147
- return found;
148
- }
149
-
150
- const orderKey = (pos, id) => [pos ?? "", id ?? ""];
151
- const before = (a, b) => (a[0] !== b[0] ? a[0] < b[0] : a[1] < b[1]);
152
-
153
- /**
154
- * @param {object} o
155
- * @param {object} o.schema
156
- * @param {object} o.store the live store (plugins/live.js storeOf)
157
- * @param {object} o.lane the fast lane (fastLane()), for its writer and epoch
158
- * @param {string} o.me this tab's client id: the author of its writes
159
- * @param {Function} [o.setTimer] @param {Function} [o.clearTimer]
160
- * @param {number} [o.quiet] ms of quiet before the writer repairs the room
161
- * @param {Function} [o.checkpointWithin] plugins/live.js; nudged after every change
162
- * @param {Function} [o.requestCheckpoint] plugins/live.js; the end of a flush
163
- * @param {Function} [o.onNotice]
164
- */
165
- export function createPageBinding({ schema, store, lane, me, setTimer = setTimeout, clearTimer = clearTimeout, quiet = 500, checkpointWithin = () => {}, requestCheckpoint = null, onNotice = () => {} }) {
166
- const blocks = new Map();
167
- const dirty = new Set();
168
- const opBlock = new Map();
169
- const own = [];
170
- const stats = { writes: 0, releases: 0, repairs: 0, follows: 0, heals: 0 };
171
- const waiters = new Set();
172
- let view = null;
173
- let epoch = null;
174
- let seq = 0;
175
- let composing = false;
176
-
177
- const isText = (t) => !!schema.nodes[t]?.isTextblock;
178
- const agreedOf = (B) => (B.text ? B.r.S : B.tomb ? null : strip(rawOf(store.authoritative, B.id)));
179
- const epochNow = () => epoch ?? lane.status.epoch ?? null;
180
-
181
- function ensure(id, value, X) {
182
- let B = blocks.get(id);
183
- if (B) return B;
184
- const raw = value ? rawOf(value, id) : null;
185
- const t = (X && X.t) || (raw && raw.t);
186
- B = { id, text: t ? isText(t) : true, r: null, tomb: false, k: seq, edited: false, refused: false, removedFrom: null, unechoed: [], timer: null };
187
- if (B.text) {
188
- B.r = createReplay(raw ? strip(raw) : null, { seq, epoch: epochNow(), me });
189
- B.r.tomb = tombOf(value, id);
190
- if (B.r.tomb) B.r.S = null;
191
- } else B.tomb = tombOf(value, id);
192
- blocks.set(id, B);
193
- return B;
194
- }
195
- for (const id of Object.keys(store.authoritative?.blocks ?? {})) ensure(id, store.authoritative);
196
-
197
- const plugin = new pmState.Plugin({
198
- // A pasted copy of a block gets a new id from identityPlugin (model.js),
199
- // which knows the original from the transaction's mapping. Clearing ids in
200
- // transformPasted would be wrong: ProseMirror runs it on a drag that moves
201
- // a block too, before it knows the drop is a move.
202
- props: {
203
- handleDOMEvents: {
204
- compositionstart() { composing = true; return false; },
205
- compositionend() { composing = false; setTimer(releaseAll, 0); return false; },
206
- },
207
- },
208
- // A block that comes back under its tombstone's id (undo of a delete, a
209
- // paste of a deleted block) is a new block.
210
- appendTransaction(trs, old, next) {
211
- if (!trs.some((tr) => tr.docChanged && !tr.getMeta(REMOTE))) return null;
212
- const had = new Set();
213
- old.doc.forEach((node) => had.add(node.attrs.id));
214
- const tr = next.tr;
215
- next.doc.forEach((node, offset) => {
216
- const id = node.attrs.id;
217
- if (id && !had.has(id) && tombOf(store.value, id)) tr.setNodeMarkup(offset, null, { ...node.attrs, id: newId("b") });
218
- });
219
- return tr.docChanged ? tr : null;
220
- },
221
- });
222
-
223
- // ---- local side ----------------------------------------------------------
224
-
225
- function dispatch(tr) {
226
- const prev = view.state;
227
- const { state, transactions } = prev.applyTransaction(tr);
228
- view.updateState(state);
229
- const local = transactions.filter((t) => !t.getMeta(REMOTE));
230
- if (local.some((t) => t.docChanged) && !transactions.some((t) => t.getMeta(REMOTE))) commit(prev.doc, state.doc, local);
231
- }
232
-
233
- function commit(prevDoc, nextDoc, trs) {
234
- const index = (doc) => {
235
- const map = new Map();
236
- doc.forEach((node) => { if (node.attrs.id) map.set(node.attrs.id, node); });
237
- return map;
238
- };
239
- const was = index(prevDoc), now = index(nextDoc);
240
- const ranges = touchedRanges(trs, nextDoc);
241
- const writes = [];
242
- for (const [id, node] of was) if (!now.has(id)) writes.push({ id, del: true, text: node.textContent });
243
- for (const [id, node] of now) {
244
- const old = was.get(id);
245
- if (old === node) continue;
246
- const value = blockJson(node);
247
- if (!old) { writes.push({ id, value }); continue; }
248
- const prevJson = blockJson(old);
249
- if (hashOf(prevJson) === hashOf(value)) continue;
250
- const B = blocks.get(id);
251
- if (!B || !B.text || !node.isTextblock || !old.isTextblock) { writes.push({ id, value }); continue; }
252
- const sp = spliceFrom({ c: prevJson.c, m: flatten(old.content).m }, { c: value.c, m: flatten(node.content).m }, ranges.get(id));
253
- const w = { a: me, e: epochNow(), k: B.k, s: [sp ?? [0, 0, "", prevJson.c.length]], f: changedFields(prevJson, value) };
254
- writes.push({ id, value: { ...value, w } });
255
- }
256
- if (!writes.length) return;
257
- const mark = store.mark;
258
- store.handle.update((d) => {
259
- if (!d.blocks) d.blocks = {};
260
- for (const x of writes) {
261
- if (x.del) {
262
- delete d.blocks[x.id];
263
- if (d.deleted) d.deleted[x.id] = true;
264
- else d.deleted = { [x.id]: true };
265
- } else d.blocks[x.id] = x.value;
266
- }
267
- });
268
- for (const x of writes) {
269
- const B = ensure(x.id, null, x.value);
270
- B.edited = true;
271
- if (x.del) B.removedFrom = x.text;
272
- }
273
- sent(mark);
274
- stats.writes++;
275
- checkpointWithin(1500);
276
- }
277
-
278
- // The store ops made since mark: which block each is for, and the text
279
- // writes the stream has not brought back yet.
280
- function sent(mark) {
281
- for (const e of store.pendingEntries()) {
282
- if (e.id <= mark) continue;
283
- const id = blockOf(e.op);
284
- if (id === null) continue;
285
- opBlock.set(e.id, id);
286
- const B = blocks.get(id);
287
- if (B && B.text && e.op.op === "set" && e.op.path.length === 2 && e.op.path[0] === "blocks") B.unechoed.push({ id: e.id, json: JSON.stringify(e.op.value), value: e.op.value, seq: null });
288
- }
289
- }
290
-
291
- function send(B, value) {
292
- const mark = store.mark;
293
- if (value === null) store.handle.delete(["blocks", B.id]);
294
- else store.handle.set(["blocks", B.id], value);
295
- sent(mark);
296
- }
297
-
298
- // Blocks with a write in flight ("*": an op on the whole map holds them all).
299
- function heldIds() {
300
- const out = new Set();
301
- for (const e of store.pendingEntries()) out.add(blockOf(e.op) ?? "*");
302
- return out;
303
- }
304
-
305
- // ---- remote side ---------------------------------------------------------
306
-
307
- store.onDelivery((ev) => {
308
- if ((ev.phase === "landed" || ev.phase === "echoed") && Number.isInteger(ev.seq)) {
309
- for (const B of blocks.values()) for (const u of B.unechoed) if (u.seq === null && u.id <= ev.through) u.seq = ev.seq;
310
- }
311
- if (ev.phase === "refused" || ev.phase === "undone") {
312
- for (const [opId, id] of opBlock) if (opId <= ev.through) { const B = blocks.get(id); if (B) { B.refused = true; dirty.add(id); } }
313
- }
314
- });
315
-
316
- function echoed(B, X) {
317
- const json = JSON.stringify(X);
318
- const i = B.unechoed.findIndex((u) => u.json === json);
319
- if (i >= 0) B.unechoed.splice(0, i + 1);
320
- }
321
-
322
- function replayOps(ops, at, prev, next) {
323
- for (const op of blockOps(ops, prev, next)) {
324
- const B = ensure(op.id, prev, op.X);
325
- dirty.add(op.id);
326
- if (op.tombstone) {
327
- if (B.text) tombstone(B.r, at);
328
- else B.tomb = true;
329
- continue;
330
- }
331
- if (!B.text) continue;
332
- const X = op.X && Array.isArray(op.X.c) ? { ...blockJson(nodeFromBlock(schema, op.id, op.X)), ...(op.X.w ? { w: op.X.w } : {}) } : op.X;
333
- if (X && X.w && X.w.a === me) echoed(B, X);
334
- step(B.r, X, at);
335
- }
336
- advance(at);
337
- }
338
-
339
- // Every replay knows its block through the stream's seq, so a write's k (its
340
- // release seq) means the same in every tab. A block with nothing left to
341
- // release shows its value as of this seq too.
342
- function advance(at) {
343
- seq = Math.max(seq, at);
344
- for (const B of blocks.values()) {
345
- if (B.text) B.r.seq = Math.max(B.r.seq, at);
346
- if (!dirty.has(B.id) && B.k !== null) B.k = Math.max(B.k, at);
347
- }
348
- }
349
-
350
- store.onRebase(({ frame, room, prev, next }) => {
351
- if (frame) {
352
- replayOps(frame.ops, frame.seq, prev, next);
353
- checkpointWithin(1500);
354
- } else if (room && room.fresh) onEpoch(room, next);
355
- else if (room && Array.isArray(room.moves)) {
356
- for (const mv of room.moves) if (mv.seq > seq) replayOps(mv.ops, mv.seq, prev, next);
357
- } else if (room) {
358
- const ids = new Set([...blocks.keys(), ...Object.keys(next?.blocks ?? {})]);
359
- for (const id of ids) {
360
- const B = ensure(id, next);
361
- dirty.add(id);
362
- if (!B.text) { B.tomb = tombOf(next, id); continue; }
363
- if (room.seq !== B.r.seq || !same(rawOf(next, id), B.r.S)) join(B.r, rawOf(next, id), room.seq, { tomb: tombOf(next, id) });
364
- // Writes the joined value already holds never come back one by one.
365
- B.unechoed = B.unechoed.filter((u) => u.seq === null || u.seq > room.seq);
366
- }
367
- }
368
- if (room) advance(room.seq);
369
- for (const B of blocks.values()) if (B.unechoed.length || B.refused) dirty.add(B.id);
370
- for (const opId of opBlock.keys()) if (!store.pendingEntries().some((e) => e.id === opId)) opBlock.delete(opId);
371
- queueMicrotask(releaseAll);
372
- for (const fn of [...waiters]) fn();
373
- });
374
-
375
- // A room's state for an epoch this tab was not in: its first join, or a room
376
- // restarted from the file after a loss. On a restart, every text block heals:
377
- // its agreed value from the lost room, plus this tab's writes the stream never
378
- // brought back, goes to the new room as one heal (replay.js).
379
- function onEpoch(room, next) {
380
- const now = lane.status.epoch;
381
- const moves = (room.moves ?? []).flatMap((mv) => blockOps(mv.ops, null, next).map((op) => ({ seq: mv.seq, ...op })));
382
- const first = epoch === null;
383
- epoch = now;
384
- seq = room.seq;
385
- const pending = new Set(store.pendingEntries().map((e) => e.id));
386
- const ids = new Set([...blocks.keys(), ...Object.keys(next?.blocks ?? {}), ...moves.map((mv) => mv.id)]);
387
- for (const id of ids) {
388
- const B = ensure(id, next, moves.find((mv) => mv.id === id)?.X);
389
- dirty.add(id);
390
- const mine = moves.filter((mv) => mv.id === id);
391
- if (!B.text) {
392
- B.tomb = tombOf(next, id);
393
- continue;
394
- }
395
- const r = B.r;
396
- const lost = B.unechoed;
397
- B.unechoed = lost.filter((u) => pending.has(u.id));
398
- const oldSeq = r.seq;
399
- const chain = [], u = [];
400
- if (!first) {
401
- for (const x of lost) {
402
- if (!x.value || !x.value.w || x.value.w.g !== undefined) continue;
403
- step(r, x.value, r.seq);
404
- for (const sp of r.log[r.log.length - 1].applied) { chain.push(sp.slice(0, 3).concat([sp[3]], sp[4] && sp[4].some(Boolean) ? [sp[4]] : [])); u.push(x.seq); }
405
- }
406
- }
407
- const healed = first ? null : r.S;
408
- join(r, rawOf(next, id), room.seq, { epoch: now, moves: room.moves ? mine : null, since: 0, tomb: tombOf(next, id) && !mine.some((mv) => mv.tombstone) });
409
- B.k = null;
410
- if (healed && !r.tomb && !same(healed, r.S)) {
411
- stats.heals++;
412
- const value = { ...strip(healed), w: { a: me, e: now, h: oldSeq, s: chain, u } };
413
- queueMicrotask(() => send(B, value));
414
- }
415
- }
416
- }
417
-
418
- // Show every changed block whose writes have all landed, in one transaction.
419
- function releaseAll() {
420
- if (!view || composing || view.composing) return;
421
- const held = heldIds();
422
- if (held.has("*")) return;
423
- const ids = [...dirty].filter((id) => !held.has(id));
424
- if (!ids.length) return;
425
- const tr = view.state.tr;
426
- const sel = view.state.selection;
427
- const at = (pos) => {
428
- const $p = view.state.doc.resolve(pos);
429
- if ($p.depth < 1) return null;
430
- return { id: $p.node(1).attrs.id, off: pos - $p.start(1) };
431
- };
432
- const text = sel instanceof TextSelection;
433
- const head = text ? at(sel.head) : null, anchor = text ? at(sel.anchor) : null;
434
- const carets = new Map();
435
- const order = [];
436
- for (const id of ids) {
437
- dirty.delete(id);
438
- const B = blocks.get(id);
439
- if (!B) continue;
440
- const S = agreedOf(B);
441
- const found = findBlock(tr.doc, id);
442
- if (!S) {
443
- if (found) remove(tr, B, found);
444
- } else if (!found) order.push(B);
445
- else {
446
- if (B.text && found.node.isTextblock && isText(S.t)) {
447
- const E = flatten(found.node.content);
448
- if (B.refused) for (const g of regions(E.c, S.c)) if (g.to > g.from && E.c.slice(g.from, g.to).trim()) notice({ kind: "refused", id, lost: E.c.slice(g.from, g.to) });
449
- const k = B.k;
450
- carets.set(id, (off) => mapCaret(B.r, me, k, off, E.c));
451
- textSteps(tr, B, found, E, S);
452
- } else if (hashOf(blockJson(found.node)) !== hashOf(S)) tr.replaceWith(found.pos, found.pos + found.node.nodeSize, nodeFromBlock(schema, id, S));
453
- order.push(B);
454
- }
455
- B.k = B.text ? B.r.seq : seq;
456
- B.refused = false;
457
- stats.releases++;
458
- }
459
- for (const B of order) place(tr, B);
460
- if (tr.docChanged) {
461
- const placeCaret = (x) => {
462
- if (!x || !x.id) return null;
463
- const f = findBlock(tr.doc, x.id);
464
- if (!f) return null;
465
- const off = carets.has(x.id) ? carets.get(x.id)(x.off) : x.off;
466
- return f.pos + 1 + Math.max(0, Math.min(off, f.node.content.size));
467
- };
468
- const h = placeCaret(head), a = placeCaret(anchor);
469
- if (h !== null && a !== null) tr.setSelection(TextSelection.create(tr.doc, a, h));
470
- tr.setMeta(REMOTE, true).setMeta("addToHistory", false).setMeta("rebased", 0);
471
- view.dispatch(tr);
472
- }
473
- for (const id of ids) if (blocks.has(id)) settle(blocks.get(id));
474
- }
475
-
476
- // A block a peer deleted leaves the editor. This tab's text in it is kept in
477
- // a notice, unless the replay already has one for this tab's writes there.
478
- function remove(tr, B, found) {
479
- const text = found.node.textContent;
480
- const noted = B.r && B.r.conflicts.some((n) => n.kind === "removed" && n.by === me);
481
- if (B.edited && !noted && text.trim() && text !== B.removedFrom) notice({ kind: "removed", id: B.id, lost: text, by: me, block: blockJson(found.node) });
482
- if (tr.doc.childCount === 1) tr.replaceWith(found.pos, found.pos + found.node.nodeSize, schema.nodes.paragraph.create());
483
- else tr.delete(found.pos, found.pos + found.node.nodeSize);
484
- }
485
-
486
- // The editor's block E becomes S: text by a diff, then marks where the text
487
- // did not change, then the node's type and attrs.
488
- function textSteps(tr, B, found, E, S) {
489
- const start = found.pos + 1;
490
- const type = found.node.type;
491
- let delta = 0;
492
- const gs = regions(E.c, S.c).map((g) => { const s = g.from + delta; delta += g.t.length - (g.to - g.from); return { ...g, s }; });
493
- for (const g of gs.reverse()) {
494
- const nodes = g.t ? unflatten(schema, g.t, (S.m ?? []).slice(g.s, g.s + g.t.length), type) : [];
495
- if (g.to > g.from) tr.replaceWith(start + g.from, start + g.to, nodes);
496
- else if (nodes.length) tr.insert(start + g.from, nodes);
497
- }
498
- const now = flatten(tr.doc.nodeAt(found.pos).content);
499
- for (let i = 0; i < S.c.length;) {
500
- if (keyAt(now.m, i) === keyAt(S.m, i)) { i++; continue; }
501
- let j = i + 1;
502
- while (j < S.c.length && keyAt(now.m, j) !== keyAt(S.m, j) && keyAt(S.m, j) === keyAt(S.m, i)) j++;
503
- if (keyAt(S.m, i)[0] === "@" || keyAt(now.m, i)[0] === "@") tr.replaceWith(start + i, start + j, unflatten(schema, S.c.slice(i, j), (S.m ?? []).slice(i, j), type));
504
- else {
505
- tr.removeMark(start + i, start + j, null);
506
- for (const mark of marksOf(schema, keyAt(S.m, i))) if (type.allowsMarkType(mark.type)) tr.addMark(start + i, start + j, mark);
507
- }
508
- i = j;
509
- }
510
- const target = schema.nodes[S.t];
511
- const node = tr.doc.nodeAt(found.pos);
512
- const attrs = {};
513
- for (const [name, spec] of Object.entries(target.spec.attrs ?? {})) attrs[name] = name === "id" ? B.id : name in S ? S[name] : spec.default ?? null;
514
- if (target !== node.type || JSON.stringify(attrs) !== JSON.stringify(node.attrs)) {
515
- if (target.validContent(node.content)) tr.setNodeMarkup(found.pos, target, attrs);
516
- else tr.replaceWith(found.pos, found.pos + node.nodeSize, nodeFromBlock(schema, B.id, S));
517
- }
518
- }
519
-
520
- // Put block B where its (pos, id) says, inserting it when the editor has none.
521
- function place(tr, B) {
522
- const S = agreedOf(B);
523
- if (!S) return;
524
- const key = orderKey(S.pos, B.id);
525
- let found = findBlock(tr.doc, B.id);
526
- if (found) {
527
- const prev = found.index > 0 ? tr.doc.child(found.index - 1) : null;
528
- const next = found.index + 1 < tr.doc.childCount ? tr.doc.child(found.index + 1) : null;
529
- const ok = (!prev || !prev.attrs.id || before(orderKey(prev.attrs.pos, prev.attrs.id), key)) && (!next || !next.attrs.id || before(key, orderKey(next.attrs.pos, next.attrs.id)));
530
- if (ok) return;
531
- tr.delete(found.pos, found.pos + found.node.nodeSize);
532
- }
533
- const node = found ? found.node : nodeFromBlock(schema, B.id, S);
534
- let at = tr.doc.content.size;
535
- let set = false;
536
- tr.doc.forEach((child, offset) => {
537
- if (set || !child.attrs.id) return;
538
- if (before(key, orderKey(child.attrs.pos, child.attrs.id))) { at = offset; set = true; }
539
- });
540
- tr.insert(at, node);
541
- // The empty block a page keeps when every block was deleted goes once one comes back.
542
- const empty = [];
543
- tr.doc.forEach((child, offset) => { if (!child.attrs.id && child.content.size === 0) empty.push(offset); });
544
- if (tr.doc.childCount > empty.length) for (const offset of empty.reverse()) tr.delete(offset, offset + tr.doc.nodeAt(offset).nodeSize);
545
- }
546
-
547
- function notice(n) {
548
- own.push(n);
549
- onNotice(n);
550
- }
551
-
552
- // ---- repair, follow ------------------------------------------------------
553
-
554
- // The writer repairs the room's value after a short quiet spell. A tab that
555
- // refused a repair it could not place follows the room after a long one, and
556
- // so does one that sees a block under its tombstone: every tab agrees it is
557
- // gone, so any tab may delete it again.
558
- function settle(B) {
559
- clearTimer(B.timer);
560
- B.timer = null;
561
- const raw = rawOf(store.authoritative, B.id);
562
- const S = agreedOf(B);
563
- if (same(raw, S)) { if (B.r) B.r.behind = false; return; }
564
- const writer = lane.isWriter();
565
- const tomb = B.text ? B.r.tomb : B.tomb;
566
- if (!writer && !(B.r && B.r.behind) && !tomb) return;
567
- B.timer = setTimer(() => (lane.isWriter() ? repair(B) : follow(B)), writer ? quiet : quiet * 4);
568
- }
569
-
570
- function repair(B) {
571
- if (heldIds().has(B.id) || composing || dirty.has(B.id)) return;
572
- const raw = rawOf(store.authoritative, B.id);
573
- const S = agreedOf(B);
574
- if (same(raw, S)) return;
575
- stats.repairs++;
576
- if (!S) send(B, null);
577
- else if (B.text) send(B, { ...S, w: { a: me, e: epochNow(), g: B.r.seq } });
578
- }
579
-
580
- function follow(B) {
581
- if (heldIds().has(B.id) || composing) return;
582
- const tomb = B.text ? B.r.tomb : B.tomb;
583
- if (tomb && rawOf(store.authoritative, B.id)) { stats.repairs++; send(B, null); return; }
584
- if (!B.text || !B.r.behind) return;
585
- stats.follows++;
586
- join(B.r, rawOf(store.authoritative, B.id), B.r.seq, { tomb: tombOf(store.authoritative, B.id) });
587
- dirty.add(B.id);
588
- releaseAll();
589
- }
590
-
591
- // ---- pending and flush ---------------------------------------------------
592
-
593
- // Resolves { ok: true } once every write this tab has made landed and, with
594
- // requestCheckpoint, once the file holds it.
595
- function flush({ timeoutMs = 10_000 } = {}) {
596
- return new Promise((resolve) => {
597
- let timer = null;
598
- const check = () => {
599
- if (store.pending.length) return;
600
- waiters.delete(check);
601
- clearTimer(timer);
602
- resolve(requestCheckpoint ? requestCheckpoint({ timeoutMs }) : { ok: true });
603
- };
604
- timer = setTimer(() => { waiters.delete(check); resolve({ ok: false, code: "timeout" }); }, timeoutMs);
605
- waiters.add(check);
606
- check();
607
- });
608
- }
609
- const unsaved = registerUnsavedState({ id: "page:live", isPending: () => store.pending.length > 0, flush });
610
- const unsavedHeld = registerUnsavedState({ id: "page:held", isPending: () => composing });
611
-
612
- return {
613
- plugin,
614
- stats,
615
- blocks,
616
- dispatch,
617
- flush,
618
- doc: () => docFromRecord(schema, store.value),
619
- attach(v) { view = v; },
620
- writerChanged() {
621
- queueMicrotask(() => {
622
- releaseAll();
623
- for (const B of blocks.values()) settle(B);
624
- });
625
- },
626
- // Every notice, as data: the replays' (inexact, merge, removed, repair,
627
- // heal, joined, replaced) and this binding's (removed, refused).
628
- // A block's writes under its tombstone come as one notice per author: its
629
- // latest write, which holds the text of the ones before.
630
- notices() {
631
- const out = [];
632
- for (const B of blocks.values()) {
633
- if (!B.r) continue;
634
- const last = new Map();
635
- for (const n of B.r.conflicts) if (n.kind === "removed") last.set(n.by, n);
636
- for (const n of B.r.conflicts) if (n.kind !== "removed" || last.get(n.by) === n) out.push({ ...n, id: B.id });
637
- }
638
- return [...out, ...own];
639
- },
640
- dispose() {
641
- unsaved.dispose();
642
- unsavedHeld.dispose();
643
- for (const B of blocks.values()) clearTimer(B.timer);
644
- view = null;
645
- },
646
- };
647
- }