@panphora/clayjs 1.12.2 → 1.13.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.
- package/dist/clay.standalone.js +3230 -2988
- package/docs/guide.md +1847 -0
- package/entries/sap.js +6 -6
- package/package.json +3 -1
- package/packed-contract.json +1 -1
- package/src/lib/unsaved-state.js +11 -2
- package/src/live/checkpoint.js +7 -0
- package/src/live/fast-lane.js +24 -10
- package/src/live/local-lane.js +14 -7
- package/src/live/moves.js +11 -0
- package/src/live/server-lane.js +2 -1
- package/src/live/store.js +32 -1
- package/src/page/binding.js +647 -0
- package/src/page/inline.js +113 -0
- package/src/page/model.js +49 -21
- package/src/page/replay.js +578 -0
- package/src/page/schema.js +1 -1
- package/src/plugins/live.js +49 -2
- package/src/vendor/hypercms.vendor.js +20 -20
|
@@ -0,0 +1,647 @@
|
|
|
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
|
+
}
|