@panphora/clayjs 1.5.3 → 1.6.1
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/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
2
|
+
import { conflicts } from "./conflicts.js";
|
|
3
|
+
import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
|
|
4
|
+
import { savePage } from "../core/save.js";
|
|
5
|
+
import { gateMarkDirty } from "../lib/dirty-gate.js";
|
|
6
|
+
import Mutation from "../lib/mutation.js";
|
|
7
|
+
import { markExplicitSave } from "../lib/user-gesture.js";
|
|
8
|
+
import { enableContentEditable } from "../core/admin-contenteditable.js";
|
|
9
|
+
import { enableOnClick } from "../core/admin-onclick.js";
|
|
10
|
+
import { enableAdminInputs } from "../core/admin-inputs.js";
|
|
11
|
+
import { enableAdminResources } from "../core/admin-resources.js";
|
|
12
|
+
|
|
13
|
+
// Revert to mine: put this tab's side of the losses it selected back on the page as
|
|
14
|
+
// an ordinary local edit, acknowledge exactly the ids that verifiably went back, then
|
|
15
|
+
// save through savePage(). The plan resolves every target against the page as it is
|
|
16
|
+
// and keeps only losses whose footprints stay apart; the newest of two that touch
|
|
17
|
+
// wins and the older is blocked. The writes run as one transaction under a
|
|
18
|
+
// MutationObserver: when any op's own check or any whole-page invariant fails, every
|
|
19
|
+
// record is undone in reverse and the page is the page it was before the click, with
|
|
20
|
+
// nothing acknowledged and nothing saved. Attributes, form control state, focus and
|
|
21
|
+
// the selection come back from a snapshot taken before the first write, and neither
|
|
22
|
+
// the page's change feed nor its undo history sees the undone writes. Everything here
|
|
23
|
+
// reads the engine's recovery data on each record and the clone the merge read as
|
|
24
|
+
// "mine"; nothing here touches a sync base, a ticket or a saved baseline: a revert is
|
|
25
|
+
// typing.
|
|
26
|
+
|
|
27
|
+
export const BLOCKED = "This spot changed again. Review the latest edit or download your copy.";
|
|
28
|
+
|
|
29
|
+
// The engine's flat convention (hyper-morph inline-merge.js): one U+FFFC per atom,
|
|
30
|
+
// one U+001E per block break, marks descended, ignored elements skipped. Kept in step
|
|
31
|
+
// by conflict-revert.test.js, which checks projections against merged.text.
|
|
32
|
+
const ATOM = "";
|
|
33
|
+
const BREAK = "\u001E";
|
|
34
|
+
const MARK_TAGS = new Set("A ABBR B BDI BDO CITE CODE DATA DEL DFN EM FONT I INS KBD MARK Q S SAMP SMALL SPAN STRIKE STRONG SUB SUP TIME TT U VAR".split(" "));
|
|
35
|
+
const ATOM_TAGS = new Set(["BR", "WBR", "IMG"]);
|
|
36
|
+
const BLOCK_TAGS = new Set("ADDRESS ARTICLE ASIDE AUDIO BLOCKQUOTE BODY CANVAS CAPTION CENTER COL COLGROUP DD DETAILS DIALOG DIR DIV DL DT FIELDSET FIGCAPTION FIGURE FOOTER FORM FRAME FRAMESET H1 H2 H3 H4 H5 H6 HEAD HEADER HGROUP HR HTML IFRAME LEGEND LI MAIN MATH MENU NAV NOSCRIPT OBJECT OL OPTGROUP OPTION P PRE SCRIPT SECTION SELECT STYLE SUMMARY SVG TABLE TBODY TD TEMPLATE TEXTAREA TFOOT TH THEAD TITLE TR UL VIDEO".split(" "));
|
|
37
|
+
const TEXT_BLOCK_TAGS = new Set("P H1 H2 H3 H4 H5 H6 DIV LI DD DT BLOCKQUOTE FIGCAPTION SUMMARY ADDRESS".split(" "));
|
|
38
|
+
// Text blocks whose content model is phrasing only: a block inside one is a broken
|
|
39
|
+
// page. DIV, LI, DD, DT, BLOCKQUOTE, FIGCAPTION and ADDRESS hold blocks by design.
|
|
40
|
+
const INLINE_ONLY_TAGS = new Set("P H1 H2 H3 H4 H5 H6 SUMMARY".split(" "));
|
|
41
|
+
const STRUCTURAL = /[\u001E]/;
|
|
42
|
+
const OBSERVE = { subtree: true, childList: true, characterData: true, characterDataOldValue: true, attributes: true, attributeOldValue: true };
|
|
43
|
+
|
|
44
|
+
const isEl = (n) => !!n && n.nodeType === 1;
|
|
45
|
+
const isMark = (n) => isEl(n) && MARK_TAGS.has(n.tagName);
|
|
46
|
+
const ignored = (el) => el.matches(SYNC_IGNORE_SELECTOR);
|
|
47
|
+
const remoteWins = (el) => el.matches(REMOTE_WINS_SELECTOR);
|
|
48
|
+
const kidsOf = (el) => (el.localName === "template" && el.content ? el.content : el);
|
|
49
|
+
const indexOf = (node) => Array.prototype.indexOf.call(node.parentNode.childNodes, node);
|
|
50
|
+
const authored = (el) => el.getAttribute("data-id") || el.getAttribute("id") || null;
|
|
51
|
+
|
|
52
|
+
function isEmptyMark(el) {
|
|
53
|
+
return !el.firstChild || (el.textContent === "" && !el.querySelector("br,img,wbr"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isInlineUnit(node) {
|
|
57
|
+
if (node.nodeType === 3) return true;
|
|
58
|
+
if (!isEl(node)) return false;
|
|
59
|
+
if (ignored(node)) return true;
|
|
60
|
+
if (ATOM_TAGS.has(node.tagName)) return true;
|
|
61
|
+
if (!MARK_TAGS.has(node.tagName)) return !BLOCK_TAGS.has(node.tagName);
|
|
62
|
+
if (remoteWins(node)) return false;
|
|
63
|
+
for (const c of node.childNodes) if (!isInlineUnit(c)) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTextBlock(el) {
|
|
68
|
+
if (!TEXT_BLOCK_TAGS.has(el.tagName) || ignored(el) || remoteWins(el)) return false;
|
|
69
|
+
for (const c of el.childNodes) if (c.nodeType === 8 || !isInlineUnit(c)) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function templateOf(fragment, root = document) {
|
|
74
|
+
for (const t of root.querySelectorAll("template")) {
|
|
75
|
+
if (t.content === fragment) return t;
|
|
76
|
+
const inner = templateOf(fragment, t.content);
|
|
77
|
+
if (inner) return inner;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Still part of the page: the document, or a live template's content.
|
|
83
|
+
function within(node, root) {
|
|
84
|
+
for (let n = node; n; n = n.nodeType === 11 ? templateOf(n) : n.parentNode) if (n === root) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const onPage = (node) => within(node, document);
|
|
88
|
+
|
|
89
|
+
function nodeAt(root, path) {
|
|
90
|
+
let n = root;
|
|
91
|
+
for (const step of path) {
|
|
92
|
+
n = step === "content" ? n.content : n.childNodes[step];
|
|
93
|
+
if (!n) return null;
|
|
94
|
+
}
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Every authored identity in a subtree, template content included.
|
|
99
|
+
function idsIn(node, out = new Set()) {
|
|
100
|
+
if (isEl(node)) {
|
|
101
|
+
const id = authored(node);
|
|
102
|
+
if (id) out.add(id);
|
|
103
|
+
}
|
|
104
|
+
if (node.nodeType === 1 || node.nodeType === 11) for (const c of kidsOf(node).childNodes) idsIn(c, out);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fragments are parsed inert. A fragment carrying any block element is blocked
|
|
109
|
+
// outright: a block inside a paragraph is a broken page, and a parsed script would
|
|
110
|
+
// run when it reaches the page.
|
|
111
|
+
function parse(html) {
|
|
112
|
+
const t = document.createElement("template");
|
|
113
|
+
t.innerHTML = html;
|
|
114
|
+
return t.content;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hasBlock = (fragment) => [...fragment.querySelectorAll("*")].some((el) => BLOCK_TAGS.has(el.tagName));
|
|
118
|
+
|
|
119
|
+
// The single-child element chain a fragment wraps its content in, outermost first.
|
|
120
|
+
function wrappers(fragment) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (let n = fragment; n.childNodes.length === 1 && isEl(n.firstChild); n = n.firstChild) out.push(n.firstChild);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Where a clone path lives now: the document, or the content of the live template
|
|
127
|
+
// the path enters. A subject inside a template never resolves to a twin outside it.
|
|
128
|
+
function scopeFor(path, apply) {
|
|
129
|
+
const at = path.lastIndexOf("content");
|
|
130
|
+
if (at < 0) return document;
|
|
131
|
+
const tpl = byIdentity(path.slice(0, at), apply);
|
|
132
|
+
return tpl && tpl.content ? tpl.content : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The one element in scope carrying the authored identity of the clone node at
|
|
136
|
+
// path, if it is unique there. Never the first equal string on the page.
|
|
137
|
+
function byIdentity(path, apply) {
|
|
138
|
+
const src = nodeAt(apply.root, path);
|
|
139
|
+
if (!isEl(src)) return null;
|
|
140
|
+
const id = authored(src);
|
|
141
|
+
const scope = id ? scopeFor(path, apply) : null;
|
|
142
|
+
if (!scope) return null;
|
|
143
|
+
const same = [...scope.querySelectorAll(src.localName)].filter((el) => el.namespaceURI === src.namespaceURI && authored(el) === id);
|
|
144
|
+
return same.length === 1 ? same[0] : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The one element on the page with el's tag and authored identity, if unique.
|
|
148
|
+
function twinOf(el) {
|
|
149
|
+
const id = authored(el);
|
|
150
|
+
if (!id) return null;
|
|
151
|
+
const same = [...document.querySelectorAll(el.localName)].filter((x) => x.namespaceURI === el.namespaceURI && authored(x) === id);
|
|
152
|
+
return same.length === 1 ? same[0] : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A ref's live node when it is still on the page with the expected node type; else
|
|
156
|
+
// the element now carrying the identity the clone's node had, or, for a subject the
|
|
157
|
+
// clone does not hold, the identity its last live node had.
|
|
158
|
+
function liveOf(ref, apply, nodeType = ref ? ref.nodeType : 1) {
|
|
159
|
+
if (!ref) return null;
|
|
160
|
+
for (const n of ref.live) if (n.nodeType === nodeType && onPage(n)) return n;
|
|
161
|
+
if (nodeType !== 1) return null;
|
|
162
|
+
if (ref.local.length) return byIdentity(ref.local[0], apply);
|
|
163
|
+
const stale = ref.live.find(isEl);
|
|
164
|
+
return stale ? twinOf(stale) : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The subject's last live node carried an identity and nothing on the page has it.
|
|
168
|
+
function provenGone(ref) {
|
|
169
|
+
const stale = ref.live.find(isEl);
|
|
170
|
+
const id = stale ? authored(stale) : null;
|
|
171
|
+
return !!id && ![...document.querySelectorAll(stale.localName)].some((x) => authored(x) === id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Text: the current projection of a scope root, offsets to DOM points
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function project(root) {
|
|
179
|
+
const container = kidsOf(root);
|
|
180
|
+
const f = { root, container, text: "", nodes: [], atoms: [], blocks: [] };
|
|
181
|
+
const walk = (list) => {
|
|
182
|
+
for (const node of list) {
|
|
183
|
+
if (node.nodeType === 3) {
|
|
184
|
+
if (!node.data) continue;
|
|
185
|
+
f.nodes.push({ node, s: f.text.length, e: f.text.length + node.data.length });
|
|
186
|
+
f.text += node.data;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!isEl(node) || ignored(node)) continue;
|
|
190
|
+
if (isTextBlock(node)) {
|
|
191
|
+
if (f.text.length && f.text[f.text.length - 1] !== BREAK) f.text += BREAK;
|
|
192
|
+
const b = { el: node, from: f.text.length, to: -1 };
|
|
193
|
+
f.blocks.push(b);
|
|
194
|
+
walk(node.childNodes);
|
|
195
|
+
f.text += BREAK;
|
|
196
|
+
b.to = f.text.length;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const empty = MARK_TAGS.has(node.tagName) && isEmptyMark(node);
|
|
200
|
+
if (ATOM_TAGS.has(node.tagName) || !MARK_TAGS.has(node.tagName) || remoteWins(node) || empty) {
|
|
201
|
+
f.atoms.push({ i: f.text.length, el: node });
|
|
202
|
+
f.text += ATOM;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
walk(node.childNodes);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(container.childNodes);
|
|
209
|
+
const lone = f.atoms.length === 1 && f.atoms[0].el.tagName === "BR" && !f.blocks.length && f.text.replace(ATOM, "").trim() === "";
|
|
210
|
+
if (lone) {
|
|
211
|
+
const at = f.atoms[0].i;
|
|
212
|
+
f.text = f.text.slice(0, at) + f.text.slice(at + 1);
|
|
213
|
+
f.atoms = [];
|
|
214
|
+
for (const n of f.nodes) if (n.s > at) { n.s--; n.e--; }
|
|
215
|
+
}
|
|
216
|
+
return f;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A flat offset as a DOM point: inside a text node, at an atom's edge, at a block's
|
|
220
|
+
// edge, or touching a text node. The engine's rawPoint, on the live tree.
|
|
221
|
+
function pointAt(f, i, edge) {
|
|
222
|
+
for (const n of f.nodes) if (edge === "start" ? n.s <= i && i < n.e : n.s < i && i <= n.e) return [n.node, i - n.s];
|
|
223
|
+
const at = edge === "start" ? i : i - 1;
|
|
224
|
+
const atom = f.atoms.find((a) => a.i === at);
|
|
225
|
+
if (atom) return [atom.el.parentNode, indexOf(atom.el) + (edge === "start" ? 0 : 1)];
|
|
226
|
+
if (f.text[at] === BREAK) {
|
|
227
|
+
const closing = f.blocks.find((b) => b.to === at + 1);
|
|
228
|
+
if (closing) return edge === "start" ? [closing.el, closing.el.childNodes.length] : [closing.el.parentNode, indexOf(closing.el) + 1];
|
|
229
|
+
const opening = f.blocks.find((b) => b.from === at + 1);
|
|
230
|
+
if (opening) return edge === "start" ? [opening.el, 0] : [opening.el.parentNode, indexOf(opening.el)];
|
|
231
|
+
}
|
|
232
|
+
for (const n of f.nodes) if (n.s === i || n.e === i) return [n.node, i - n.s];
|
|
233
|
+
return [f.container, i === 0 ? 0 : f.container.childNodes.length];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function point(node, offset) {
|
|
237
|
+
const r = document.createRange();
|
|
238
|
+
r.setStart(node, offset);
|
|
239
|
+
r.setEnd(node, offset);
|
|
240
|
+
return r;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A DOM point as a flat offset in a projection: inside one of its text nodes, else
|
|
244
|
+
// the offset of the first unit at or after the point. Null when the point is not in
|
|
245
|
+
// the projected tree.
|
|
246
|
+
function offsetOf(f, node, off) {
|
|
247
|
+
if (node.nodeType === 3) {
|
|
248
|
+
const n = f.nodes.find((x) => x.node === node);
|
|
249
|
+
return n ? n.s + off : null;
|
|
250
|
+
}
|
|
251
|
+
if (node !== f.container && !within(node, f.root)) return null;
|
|
252
|
+
const at = point(node, off);
|
|
253
|
+
let best = f.text.length;
|
|
254
|
+
const after = (n, o) => {
|
|
255
|
+
try {
|
|
256
|
+
return at.comparePoint(n, o) >= 0;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
for (const n of f.nodes) if (after(n.node, 0)) best = Math.min(best, n.s);
|
|
262
|
+
for (const a of f.atoms) if (after(a.el.parentNode, indexOf(a.el))) best = Math.min(best, a.i);
|
|
263
|
+
return best;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// apply.js's trimHunk: a word hunk shrunk to the characters that differ.
|
|
267
|
+
function trim(old, h) {
|
|
268
|
+
const was = old.slice(h.bs, h.be);
|
|
269
|
+
let p = 0;
|
|
270
|
+
while (p < was.length && p < h.text.length && was[p] === h.text[p]) p++;
|
|
271
|
+
let s = 0;
|
|
272
|
+
while (s < was.length - p && s < h.text.length - p && was[was.length - 1 - s] === h.text[h.text.length - 1 - s]) s++;
|
|
273
|
+
return { bs: h.bs + p, be: h.be - s, text: h.text.slice(p, h.text.length - s) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// [S, E) in the old projection as an interval of the current one. A change before
|
|
277
|
+
// the range shifts it; an insertion exactly at the start is before it; an insertion
|
|
278
|
+
// exactly at the end stays outside; a change inside belongs to this spot. A hunk
|
|
279
|
+
// crossing a boundary, or an atom or break changing inside, gives null: the spot is
|
|
280
|
+
// not this record's any more.
|
|
281
|
+
function rebase(oldText, newText, S, E) {
|
|
282
|
+
if (oldText === newText) return { s: S, e: E };
|
|
283
|
+
let shift = 0;
|
|
284
|
+
let inner = 0;
|
|
285
|
+
const hunks = HyperMorph.diff(oldText, newText).map((h) => trim(oldText, h)).sort((a, b) => a.bs - b.bs);
|
|
286
|
+
for (const h of hunks) {
|
|
287
|
+
if (h.bs === h.be && !h.text) continue;
|
|
288
|
+
const d = h.text.length - (h.be - h.bs);
|
|
289
|
+
if (h.be <= S) shift += d;
|
|
290
|
+
else if (h.bs >= E) continue;
|
|
291
|
+
else if (h.bs >= S && h.be <= E) {
|
|
292
|
+
if (STRUCTURAL.test(oldText.slice(h.bs, h.be) + h.text)) return null;
|
|
293
|
+
inner += d;
|
|
294
|
+
} else return null;
|
|
295
|
+
}
|
|
296
|
+
return { s: S + shift, e: E + shift + inner };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Where the record's scope text begins in the subject now: the scope start the
|
|
300
|
+
// engine recorded, when it is still on the page inside the subject; else the start
|
|
301
|
+
// of a subject that is one text block, whose scope is its whole content; else the
|
|
302
|
+
// one place the merged scope text occurs. Two occurrences or none cannot be told
|
|
303
|
+
// apart.
|
|
304
|
+
function scopeStart(t, live, f) {
|
|
305
|
+
const sc = t.liveScope;
|
|
306
|
+
if (sc && sc.startContainer && within(sc.startContainer, live) && within(sc.endContainer, live)) {
|
|
307
|
+
const at = offsetOf(f, sc.startContainer, sc.startOffset);
|
|
308
|
+
if (at !== null) return at;
|
|
309
|
+
}
|
|
310
|
+
if (isTextBlock(live)) return 0;
|
|
311
|
+
const first = t.merged.text ? f.text.indexOf(t.merged.text) : -1;
|
|
312
|
+
return first >= 0 && f.text.indexOf(t.merged.text, first + 1) < 0 ? first : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The text block the range sits in, or the subject when the range is not inside
|
|
316
|
+
// one: the unit the after-write invariant projects.
|
|
317
|
+
function scopeOf(range, root) {
|
|
318
|
+
let n = range.commonAncestorContainer;
|
|
319
|
+
if (n.nodeType === 3) n = n.parentNode;
|
|
320
|
+
for (; n && n !== root; n = n.parentNode) if (isEl(n) && isTextBlock(n)) return n;
|
|
321
|
+
return root;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Text: marks around the spot
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
const sameMark = (a, b) =>
|
|
329
|
+
a.tagName === b.tagName && a.attributes.length === b.attributes.length &&
|
|
330
|
+
[...a.attributes].every((at) => b.getAttribute(at.name) === at.value);
|
|
331
|
+
|
|
332
|
+
// The marks enclosing a node, up to the nearest non-mark element, outermost first.
|
|
333
|
+
function marksAbove(node, root) {
|
|
334
|
+
const out = [];
|
|
335
|
+
for (let n = isEl(node) ? node : node.parentNode; n && n !== root && isMark(n); n = n.parentNode) out.unshift(n);
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A collapsed point at the edge of marks the fragment does not carry moves outside
|
|
340
|
+
// them, nested marks included: the local side wrote its text next to the mark, not
|
|
341
|
+
// inside it.
|
|
342
|
+
function hoist(point, root, carried) {
|
|
343
|
+
let [node, off] = point;
|
|
344
|
+
if (node.nodeType === 3) {
|
|
345
|
+
if (off !== 0 && off !== node.data.length) return point;
|
|
346
|
+
[node, off] = [node.parentNode, indexOf(node) + (off === 0 ? 0 : 1)];
|
|
347
|
+
}
|
|
348
|
+
let lifted = false;
|
|
349
|
+
while (node !== root && isMark(node) && (off === 0 || off === node.childNodes.length) && !carried.some((w) => sameMark(w, node))) {
|
|
350
|
+
[node, off] = [node.parentNode, indexOf(node) + (off === 0 ? 0 : 1)];
|
|
351
|
+
lifted = true;
|
|
352
|
+
}
|
|
353
|
+
return lifted ? [node, off] : point;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Decide, before any write, how the fragment's wrappers meet the marks around the
|
|
357
|
+
// range. Both ends of the range must sit under the same marks, and those marks must
|
|
358
|
+
// equal, outermost first, the fragment's outer wrappers: the text goes inside them
|
|
359
|
+
// once and the remaining wrappers come along. Anything else (a mark this tab removed,
|
|
360
|
+
// a mark the other side added, a range crossing a mark's edge, a wrapper that would
|
|
361
|
+
// nest a live mark of its own tag) cannot be proven and blocks.
|
|
362
|
+
function reconcile(op, range, root) {
|
|
363
|
+
const chain = marksAbove(range.startContainer, root);
|
|
364
|
+
const atEnd = marksAbove(range.endContainer, root);
|
|
365
|
+
if (chain.length !== atEnd.length || chain.some((c, i) => c !== atEnd[i])) return false;
|
|
366
|
+
if (chain.length > op.wrappers.length || chain.some((c, i) => !sameMark(c, op.wrappers[i]))) return false;
|
|
367
|
+
op.peel = chain.length;
|
|
368
|
+
return !op.wrappers.slice(op.peel).some((w) => chain.some((c) => c.tagName === w.tagName));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The engine records a clash whose neighbour shares the word one level up and
|
|
372
|
+
// serializes the paragraph around the local text. That one wrapper goes when it is
|
|
373
|
+
// the live text block holding both ends of the range, by tag and authored identity;
|
|
374
|
+
// an id-less wrapper matches by being that block.
|
|
375
|
+
function peelBlock(fragment, start, end, live) {
|
|
376
|
+
const outer = fragment.childNodes.length === 1 && isEl(fragment.firstChild) ? fragment.firstChild : null;
|
|
377
|
+
if (!outer || !BLOCK_TAGS.has(outer.tagName)) return;
|
|
378
|
+
const range = document.createRange();
|
|
379
|
+
range.setStart(...start);
|
|
380
|
+
range.setEnd(...end);
|
|
381
|
+
const block = scopeOf(range, live);
|
|
382
|
+
if (block === live || block.tagName !== outer.tagName || block.namespaceURI !== outer.namespaceURI || authored(block) !== authored(outer)) return;
|
|
383
|
+
outer.replaceWith(...outer.childNodes);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// A fragment that is one run of text, or nothing, is written into a single text node
|
|
387
|
+
// with one replaceData, which undo can reverse.
|
|
388
|
+
function plainOf(op) {
|
|
389
|
+
if (!op.parsed) return op.fragment;
|
|
390
|
+
const kids = op.parsed.childNodes;
|
|
391
|
+
if (!kids.length) return "";
|
|
392
|
+
return kids.length === 1 && kids[0].nodeType === 3 ? kids[0].data : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function planText(rec, apply) {
|
|
396
|
+
const t = rec.recovery.text;
|
|
397
|
+
const subject = rec.recovery.subject;
|
|
398
|
+
const live = liveOf(subject, apply, subject.nodeType);
|
|
399
|
+
if (!live) return null;
|
|
400
|
+
const local = t.local.text.slice(t.local.start, t.local.end);
|
|
401
|
+
if (STRUCTURAL.test(t.merged.text.slice(t.merged.start, t.merged.end)) || local.includes(BREAK)) return null;
|
|
402
|
+
const op = { kind: "text", id: rec.id, ticket: apply.ticket, key: subject.key, fragment: t.local.fragment, encoding: t.encoding, local, ids: new Set(), range: document.createRange() };
|
|
403
|
+
if (live.nodeType === 8) {
|
|
404
|
+
const span = rebase(t.merged.text, live.data, t.merged.start, t.merged.end);
|
|
405
|
+
if (!span) return null;
|
|
406
|
+
op.range.setStart(live, span.s);
|
|
407
|
+
op.range.setEnd(live, span.e);
|
|
408
|
+
return { ...op, root: live, owner: live, scope: live, s: span.s, e: span.e, before: live.data, bs: span.s, be: span.e };
|
|
409
|
+
}
|
|
410
|
+
// A textarea shows its value, which typing moves away from its child text: the
|
|
411
|
+
// spot is found in the value and both are written.
|
|
412
|
+
if (live.localName === "textarea") {
|
|
413
|
+
if (op.encoding !== "plain") return null;
|
|
414
|
+
const span = rebase(t.merged.text, live.value, t.merged.start, t.merged.end);
|
|
415
|
+
if (!span) return null;
|
|
416
|
+
op.range.selectNodeContents(live);
|
|
417
|
+
return { ...op, root: live, field: live, scope: live, f: { text: live.value }, s: span.s, e: span.e, before: live.value, bs: span.s, be: span.e };
|
|
418
|
+
}
|
|
419
|
+
const f = project(live);
|
|
420
|
+
const from = scopeStart(t, live, f);
|
|
421
|
+
if (from === null) return null;
|
|
422
|
+
const span = rebase(t.merged.text, f.text.slice(from), t.merged.start, t.merged.end);
|
|
423
|
+
if (!span) return null;
|
|
424
|
+
const s = from + span.s;
|
|
425
|
+
const e = from + span.e;
|
|
426
|
+
let start = pointAt(f, s, "start");
|
|
427
|
+
let end = s === e ? start : pointAt(f, e, "end");
|
|
428
|
+
op.parsed = op.encoding === "html" ? parse(op.fragment) : null;
|
|
429
|
+
if (op.parsed) peelBlock(op.parsed, start, end, live);
|
|
430
|
+
if (op.parsed && hasBlock(op.parsed)) return null;
|
|
431
|
+
op.wrappers = op.parsed ? wrappers(op.parsed) : [];
|
|
432
|
+
if (op.parsed) idsIn(op.parsed, op.ids);
|
|
433
|
+
op.plain = plainOf(op);
|
|
434
|
+
if (s === e) start = end = hoist(start, live, op.wrappers);
|
|
435
|
+
op.range.setStart(...start);
|
|
436
|
+
op.range.setEnd(...end);
|
|
437
|
+
if (!reconcile(op, op.range, live)) return null;
|
|
438
|
+
const scope = scopeOf(op.range, live);
|
|
439
|
+
const g = scope === live ? f : project(scope);
|
|
440
|
+
const bs = offsetOf(g, ...start);
|
|
441
|
+
const be = s === e ? bs : offsetOf(g, ...end);
|
|
442
|
+
if (bs === null || be === null || g.text.slice(bs, be) !== f.text.slice(s, e)) return null;
|
|
443
|
+
return { ...op, root: live, f, s, e, scope, before: g.text, bs, be };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function applyText(op) {
|
|
447
|
+
if (op.owner) {
|
|
448
|
+
const d = op.owner.data;
|
|
449
|
+
op.owner.data = d.slice(0, op.s) + op.fragment + d.slice(op.e);
|
|
450
|
+
op.check = () => op.owner.data.slice(op.s, op.s + op.fragment.length) === op.fragment && onPage(op.owner);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (op.field) {
|
|
454
|
+
const field = op.field;
|
|
455
|
+
const next = field.value.slice(0, op.s) + op.fragment + field.value.slice(op.e);
|
|
456
|
+
field.textContent = next;
|
|
457
|
+
if (field.value !== next) {
|
|
458
|
+
field.value = next;
|
|
459
|
+
op.wroteProperty = true;
|
|
460
|
+
}
|
|
461
|
+
if (field.hasAttribute("data-value")) field.setAttribute("data-value", next);
|
|
462
|
+
op.check = () => onPage(field) && field.value === next && field.textContent === next;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const range = op.range;
|
|
466
|
+
const node = range.startContainer;
|
|
467
|
+
if (op.plain !== null && node.nodeType === 3 && range.endContainer === node) {
|
|
468
|
+
const at = range.startOffset;
|
|
469
|
+
node.replaceData(at, range.endOffset - at, op.plain);
|
|
470
|
+
op.check = () => onPage(node) && node.data.slice(at, at + op.plain.length) === op.plain;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
range.deleteContents();
|
|
474
|
+
let nodes = [];
|
|
475
|
+
if (op.fragment) {
|
|
476
|
+
if (op.parsed) {
|
|
477
|
+
const frag = document.importNode(op.parsed, true);
|
|
478
|
+
const ws = wrappers(frag);
|
|
479
|
+
for (let depth = 0; depth < op.peel; depth++) ws[depth].replaceWith(...ws[depth].childNodes);
|
|
480
|
+
nodes = [...frag.childNodes];
|
|
481
|
+
range.insertNode(frag);
|
|
482
|
+
} else {
|
|
483
|
+
nodes = [document.createTextNode(op.fragment)];
|
|
484
|
+
range.insertNode(nodes[0]);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
op.check = () => nodes.every(onPage) && (op.parsed || !nodes.length || nodes[0].data === op.fragment);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Attributes and structure
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
function planAttr(rec, apply) {
|
|
495
|
+
const el = liveOf(rec.recovery.subject, apply, 1);
|
|
496
|
+
if (!el) return null;
|
|
497
|
+
const a = rec.recovery.attribute;
|
|
498
|
+
const op = { kind: "attr", id: rec.id, ticket: apply.ticket, key: rec.recovery.subject.key, el, ns: a.namespaceURI, name: a.qualifiedName, local: a.localName, value: rec.local ?? null, ids: new Set() };
|
|
499
|
+
try {
|
|
500
|
+
if (op.ns === null) document.createAttribute(op.name);
|
|
501
|
+
else document.createAttributeNS(op.ns, op.name);
|
|
502
|
+
} catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return op;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Input types whose value attribute is not what the control shows.
|
|
509
|
+
const DEFAULT_VALUE_TYPES = new Set(["hidden", "submit", "image", "reset", "button", "checkbox", "radio", "file"]);
|
|
510
|
+
|
|
511
|
+
// What a control shows for a value: the browser sanitizes some input types (a colour
|
|
512
|
+
// is lowercased, a URL trimmed), so the check compares against that.
|
|
513
|
+
function shownValue(el, value) {
|
|
514
|
+
const probe = document.createElement("input");
|
|
515
|
+
probe.type = el.type;
|
|
516
|
+
probe.value = value;
|
|
517
|
+
return probe.value;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The live property a form-state attribute stands for, and the state it should show:
|
|
521
|
+
// once a person has typed or clicked, the control shows the property, and a [persist]
|
|
522
|
+
// save serializes it, whatever the attribute says.
|
|
523
|
+
function formState(op) {
|
|
524
|
+
const el = op.el;
|
|
525
|
+
if (op.ns !== null || el.namespaceURI !== "http://www.w3.org/1999/xhtml") return null;
|
|
526
|
+
if (el.localName === "input" && op.name === "checked" && (el.type === "checkbox" || el.type === "radio")) return ["checked", op.value !== null];
|
|
527
|
+
if (el.localName === "input" && op.name === "value" && !DEFAULT_VALUE_TYPES.has(el.type)) return ["value", shownValue(el, op.value ?? "")];
|
|
528
|
+
if (el.localName === "option" && op.name === "selected") return ["selected", op.value !== null];
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function applyAttr(op) {
|
|
533
|
+
if (op.ns === null) {
|
|
534
|
+
if (op.value === null) op.el.removeAttribute(op.name);
|
|
535
|
+
else op.el.setAttribute(op.name, op.value);
|
|
536
|
+
} else if (op.value === null) op.el.removeAttributeNS(op.ns, op.local);
|
|
537
|
+
else op.el.setAttributeNS(op.ns, op.name, op.value);
|
|
538
|
+
// A control that still follows its attribute has taken the state already; the
|
|
539
|
+
// property is written only when it did not, so no pristine control turns dirty.
|
|
540
|
+
const state = formState(op);
|
|
541
|
+
if (state && op.el[state[0]] !== state[1]) {
|
|
542
|
+
op.el[state[0]] = state[1];
|
|
543
|
+
op.wroteProperty = true;
|
|
544
|
+
}
|
|
545
|
+
const now = () => (op.ns === null ? op.el.getAttribute(op.name) : op.el.getAttributeNS(op.ns, op.local));
|
|
546
|
+
op.check = () => onPage(op.el) && now() === op.value && (!state || op.el[state[0]] === state[1]);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function planStructure(rec, apply) {
|
|
550
|
+
const s = rec.recovery.structure;
|
|
551
|
+
const subject = rec.recovery.subject;
|
|
552
|
+
const live = liveOf(subject, apply, subject.nodeType);
|
|
553
|
+
const base = { id: rec.id, ticket: apply.ticket, key: subject.key, apply, ids: new Set() };
|
|
554
|
+
const parentOf = (placement) => (placement ? liveOf(placement.parent, apply, 1) : null);
|
|
555
|
+
// A deletion whose subject is provably gone is achieved: nothing to write, nothing
|
|
556
|
+
// to protect.
|
|
557
|
+
if (s.localAction === "deleted") {
|
|
558
|
+
if (live) return { ...base, kind: "remove", node: live };
|
|
559
|
+
return provenGone(subject) ? { ...base, kind: "done", check: () => true } : null;
|
|
560
|
+
}
|
|
561
|
+
if (s.localAction === "moved") {
|
|
562
|
+
const parent = parentOf(s.localPlacement);
|
|
563
|
+
if (!live || !parent || live === parent || within(parent, live)) return null;
|
|
564
|
+
return { ...base, kind: "move", node: live, parent, placement: s.localPlacement };
|
|
565
|
+
}
|
|
566
|
+
if (s.localAction === "reordered") {
|
|
567
|
+
if (!live || !s.localOrder) return null;
|
|
568
|
+
const container = kidsOf(live);
|
|
569
|
+
const order = s.localOrder.map((r) => liveOf(r, apply, r.nodeType)).filter((n) => n && n.parentNode === container);
|
|
570
|
+
if (order.length < 2) return null;
|
|
571
|
+
return { ...base, kind: "reorder", parent: live, order };
|
|
572
|
+
}
|
|
573
|
+
if (s.localAction === "inserted") {
|
|
574
|
+
const parent = parentOf(s.localPlacement);
|
|
575
|
+
if (!parent) return null;
|
|
576
|
+
if (live && live.parentNode !== kidsOf(parent)) return null;
|
|
577
|
+
if (s.fragmentKind !== "element") {
|
|
578
|
+
if (typeof s.localFragment !== "string") return null;
|
|
579
|
+
return { ...base, kind: "run", node: live, parent, placement: s.localPlacement, text: s.localFragment, nodeType: subject.nodeType };
|
|
580
|
+
}
|
|
581
|
+
const src = subject.local.length ? nodeAt(apply.root, subject.local[0]) : null;
|
|
582
|
+
if (!isEl(src)) return null;
|
|
583
|
+
const mode = !live ? "place" : live.tagName === src.tagName && live.namespaceURI === src.namespaceURI ? "morph" : "replace";
|
|
584
|
+
return { ...base, kind: "realize", node: live, parent, placement: s.localPlacement, src, mode, ids: idsIn(src) };
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Where a restored or moved node goes: before the first `before` sibling still under
|
|
590
|
+
// the parent, else after the first `after` sibling still there, else the start or
|
|
591
|
+
// end the empty arrays encode.
|
|
592
|
+
function place(node, parent, placement, apply) {
|
|
593
|
+
const container = kidsOf(parent);
|
|
594
|
+
const under = (refs) => {
|
|
595
|
+
for (const r of refs) {
|
|
596
|
+
const n = liveOf(r, apply, r.nodeType);
|
|
597
|
+
if (n && n !== node && n.parentNode === container) return n;
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
};
|
|
601
|
+
const before = under(placement.before);
|
|
602
|
+
if (before) return container.insertBefore(node, before);
|
|
603
|
+
const after = under(placement.after);
|
|
604
|
+
if (after) return container.insertBefore(node, after.nextSibling);
|
|
605
|
+
return placement.before.length ? container.insertBefore(node, container.firstChild) : container.appendChild(node);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// A clone from a save-domain apply (disk lane) is the inert saved form; it wakes up
|
|
609
|
+
// the way an incoming disk document does, before it reaches the page.
|
|
610
|
+
function realized(op) {
|
|
611
|
+
const imported = document.importNode(op.src, true);
|
|
612
|
+
const holder = document.createDocumentFragment();
|
|
613
|
+
holder.append(imported);
|
|
614
|
+
if (op.apply.domain === "save") {
|
|
615
|
+
enableContentEditable(holder);
|
|
616
|
+
enableOnClick(holder);
|
|
617
|
+
enableAdminInputs(holder);
|
|
618
|
+
enableAdminResources(holder);
|
|
619
|
+
}
|
|
620
|
+
return imported;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// The graph, in an order where every step's preconditions still hold: moved nodes
|
|
624
|
+
// wait in the parked fragment, restored subtrees and runs are written where the plan
|
|
625
|
+
// decided, moves land, reorders settle, removals come last.
|
|
626
|
+
function applyStructure(ops, parked) {
|
|
627
|
+
const moves = ops.filter((op) => op.kind === "move");
|
|
628
|
+
for (const op of moves) parked.append(op.node);
|
|
629
|
+
for (const op of ops) {
|
|
630
|
+
if (op.kind === "run") {
|
|
631
|
+
if (op.node) op.node.data = op.text;
|
|
632
|
+
else op.node = place(op.nodeType === 8 ? document.createComment(op.text) : document.createTextNode(op.text), op.parent, op.placement, op.apply);
|
|
633
|
+
op.check = () => onPage(op.node) && op.node.parentNode === kidsOf(op.parent) && op.node.data === op.text;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (op.kind !== "realize") continue;
|
|
637
|
+
const fresh = realized(op);
|
|
638
|
+
if (op.mode === "morph") HyperMorph.morphElement(op.node, fresh, { ignore: ignored, scripts: { execute: false } });
|
|
639
|
+
else if (op.mode === "replace") {
|
|
640
|
+
op.node.replaceWith(fresh);
|
|
641
|
+
op.node = fresh;
|
|
642
|
+
} else op.node = place(fresh, op.parent, op.placement, op.apply);
|
|
643
|
+
op.check = () => onPage(op.node) && op.node.parentNode === kidsOf(op.parent);
|
|
644
|
+
}
|
|
645
|
+
for (const op of moves) {
|
|
646
|
+
place(op.node, op.parent, op.placement, op.apply);
|
|
647
|
+
op.check = () => op.node.parentNode === kidsOf(op.parent) && onPage(op.node);
|
|
648
|
+
}
|
|
649
|
+
for (const op of ops) {
|
|
650
|
+
if (op.kind !== "reorder") continue;
|
|
651
|
+
const container = kidsOf(op.parent);
|
|
652
|
+
const participants = op.order.filter((n) => n.parentNode === container);
|
|
653
|
+
const queue = participants.slice();
|
|
654
|
+
const target = [...container.childNodes].map((n) => (participants.includes(n) ? queue.shift() : n));
|
|
655
|
+
let ref = null;
|
|
656
|
+
for (let i = target.length - 1; i >= 0; i--) {
|
|
657
|
+
const n = target[i];
|
|
658
|
+
if (participants.includes(n) && n.nextSibling !== ref) container.insertBefore(n, ref);
|
|
659
|
+
ref = n;
|
|
660
|
+
}
|
|
661
|
+
op.check = () => {
|
|
662
|
+
const now = [...container.childNodes].filter((n) => participants.includes(n));
|
|
663
|
+
return now.length === participants.length && now.every((n, i) => n === participants[i]);
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
for (const op of ops) {
|
|
667
|
+
if (op.kind !== "remove") continue;
|
|
668
|
+
op.node.remove();
|
|
669
|
+
op.check = () => !onPage(op.node);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Footprints: which losses may share one revert
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
// The subtrees an op rewrites, removes or moves as a whole.
|
|
678
|
+
function owned(op) {
|
|
679
|
+
if (op.kind === "move" || op.kind === "remove") return [op.node];
|
|
680
|
+
if (op.kind === "realize" || op.kind === "run") return op.node ? [op.node] : [];
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// The containers an op writes inside of.
|
|
685
|
+
function into(op) {
|
|
686
|
+
if (op.kind === "text") return [op.scope];
|
|
687
|
+
if (op.kind === "move" || op.kind === "reorder") return [op.parent];
|
|
688
|
+
if ((op.kind === "realize" || op.kind === "run") && !op.node) return [op.parent];
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const heldBy = (op) => (op.kind === "attr" ? [op.el] : [...owned(op), ...into(op)]);
|
|
693
|
+
|
|
694
|
+
// Two spans meet when one starts before the other ends, or both are the same
|
|
695
|
+
// insertion point. Compared as DOM points, so scopes of different depths agree.
|
|
696
|
+
function spansMeet(a, b) {
|
|
697
|
+
try {
|
|
698
|
+
const startA = point(a.startContainer, a.startOffset);
|
|
699
|
+
const startB = point(b.startContainer, b.startOffset);
|
|
700
|
+
if (a.collapsed && b.collapsed) return startA.comparePoint(b.startContainer, b.startOffset) === 0;
|
|
701
|
+
return startA.comparePoint(b.endContainer, b.endOffset) > 0 && startB.comparePoint(a.endContainer, a.endOffset) > 0;
|
|
702
|
+
} catch {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function contains(outer, inner) {
|
|
708
|
+
try {
|
|
709
|
+
return outer.comparePoint(inner.startContainer, inner.startOffset) === 0 && outer.comparePoint(inner.endContainer, inner.endOffset) === 0;
|
|
710
|
+
} catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function inRange(range, node) {
|
|
716
|
+
try {
|
|
717
|
+
return range.intersectsNode(node) && !within(range.commonAncestorContainer, node);
|
|
718
|
+
} catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// An identity one op's payload carries that the other op's payload also carries,
|
|
724
|
+
// or that sits inside a node the other op holds: writing both makes two of it.
|
|
725
|
+
function meetIds(a, b) {
|
|
726
|
+
if (!a.ids.size && !b.ids.size) return false;
|
|
727
|
+
const inside = (op) => {
|
|
728
|
+
const out = new Set();
|
|
729
|
+
for (const n of heldBy(op)) idsIn(n, out);
|
|
730
|
+
return out;
|
|
731
|
+
};
|
|
732
|
+
const heldA = a.ids.size ? null : inside(a);
|
|
733
|
+
const heldB = inside(b);
|
|
734
|
+
return [...a.ids].some((id) => b.ids.has(id) || heldB.has(id)) || [...b.ids].some((id) => (heldA || inside(a)).has(id));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function attrMeets(a, b) {
|
|
738
|
+
if (b.kind === "text") return inRange(b.range, a.el);
|
|
739
|
+
return owned(b).some((n) => within(a.el, n)) || b.ids.has(authored(a.el));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function textMeets(a, b) {
|
|
743
|
+
if (b.kind === "reorder") return within(b.parent, a.scope) || meetIds(a, b);
|
|
744
|
+
if (owned(b).some((n) => within(n, a.scope) || within(a.scope, n))) return true;
|
|
745
|
+
if (into(b).some((n) => within(n, a.scope))) return true;
|
|
746
|
+
return meetIds(a, b);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function reorderMeets(r, b) {
|
|
750
|
+
if (owned(b).some((n) => r.order.includes(n) || within(r.parent, n))) return true;
|
|
751
|
+
if (into(b).includes(r.parent)) return true;
|
|
752
|
+
return meetIds(r, b);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Two selected ops touch when one works inside what the other owns, both write the
|
|
756
|
+
// same spot, or their payloads would duplicate an identity. Two insertions into the
|
|
757
|
+
// same container, an attribute and the text under it, and two attributes of one
|
|
758
|
+
// element stay apart.
|
|
759
|
+
function touch(a, b) {
|
|
760
|
+
if (a.kind === "done" || b.kind === "done") return false;
|
|
761
|
+
if (a.kind === "text" && b.kind === "text") return spansMeet(a.range, b.range);
|
|
762
|
+
if (a.kind === "attr" && b.kind === "attr") return a.el === b.el && a.ns === b.ns && a.local === b.local;
|
|
763
|
+
if (a.kind === "attr") return attrMeets(a, b);
|
|
764
|
+
if (b.kind === "attr") return attrMeets(b, a);
|
|
765
|
+
if (a.kind === "text") return textMeets(a, b);
|
|
766
|
+
if (b.kind === "text") return textMeets(b, a);
|
|
767
|
+
if (a.kind === "reorder" && b.kind === "reorder") return a.parent === b.parent || meetIds(a, b);
|
|
768
|
+
if (a.kind === "reorder") return reorderMeets(a, b);
|
|
769
|
+
if (b.kind === "reorder") return reorderMeets(b, a);
|
|
770
|
+
for (const x of owned(a)) for (const y of owned(b)) if (within(x, y) || within(y, x)) return true;
|
|
771
|
+
for (const x of owned(a)) for (const y of into(b)) if (within(y, x)) return true;
|
|
772
|
+
for (const x of owned(b)) for (const y of into(a)) if (within(y, x)) return true;
|
|
773
|
+
return meetIds(a, b);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// An op touches the footprint of a loss that could not be planned when it holds
|
|
777
|
+
// that node, works inside it, or carries an identity it contains.
|
|
778
|
+
function touchesNode(op, c) {
|
|
779
|
+
if (op.kind === "done") return false;
|
|
780
|
+
if (op.kind === "attr") return within(op.el, c.node);
|
|
781
|
+
if (heldBy(op).some((n) => within(n, c.node) || within(c.node, n))) return true;
|
|
782
|
+
if (!op.ids.size) return false;
|
|
783
|
+
if (!c.ids) c.ids = idsIn(c.node);
|
|
784
|
+
return [...op.ids].some((id) => c.ids.has(id));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// The newer op (k) achieves the older's local state: the same live node, and the
|
|
788
|
+
// older's whole range inside the newer's. Nothing else covers.
|
|
789
|
+
function covers(k, op) {
|
|
790
|
+
return k.kind === "text" && op.kind === "text" && !k.field && k.root === op.root && contains(k.range, op.range);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// What a loss that cannot be planned still protects, as narrow as the page allows:
|
|
794
|
+
// the text block around its recorded clash, else around its recorded scope, else its
|
|
795
|
+
// live subject, else its live placement parent, else the nearest ancestor of its
|
|
796
|
+
// clone path still on the page by identity, else the whole page.
|
|
797
|
+
function footprintOf(rec, apply) {
|
|
798
|
+
const r = rec.recovery;
|
|
799
|
+
const subject = r.subject;
|
|
800
|
+
for (const span of r.text ? [r.text.liveSpan, r.text.liveScope] : []) {
|
|
801
|
+
if (span && span.startContainer && onPage(span.startContainer) && onPage(span.endContainer)) return blockAround(scopeRoot(span));
|
|
802
|
+
}
|
|
803
|
+
const live = subject ? liveOf(subject, apply, subject.nodeType) : null;
|
|
804
|
+
if (live) return live;
|
|
805
|
+
const parent = r.structure && r.structure.localPlacement ? liveOf(r.structure.localPlacement.parent, apply, 1) : null;
|
|
806
|
+
if (parent) return parent;
|
|
807
|
+
const path = subject && subject.local.length ? subject.local[0] : [];
|
|
808
|
+
for (let i = path.length - 1; i > 0; i--) {
|
|
809
|
+
if (path[i - 1] === "content") continue;
|
|
810
|
+
const el = byIdentity(path.slice(0, i), apply);
|
|
811
|
+
if (el) return el;
|
|
812
|
+
}
|
|
813
|
+
return document.documentElement;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function scopeRoot(scope) {
|
|
817
|
+
const above = new Set();
|
|
818
|
+
for (let n = scope.startContainer; n; n = n.parentNode) above.add(n);
|
|
819
|
+
let c = scope.endContainer;
|
|
820
|
+
while (c && !above.has(c)) c = c.parentNode;
|
|
821
|
+
return c.nodeType === 1 || c.nodeType === 11 ? c : c.parentNode;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function blockAround(node) {
|
|
825
|
+
for (let n = node; n; n = n.parentNode) if (isEl(n) && TEXT_BLOCK_TAGS.has(n.tagName)) return n;
|
|
826
|
+
return node;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function planOne(rec, apply) {
|
|
830
|
+
const r = rec.recovery;
|
|
831
|
+
if (r.applied === false || r.unavailable) return null;
|
|
832
|
+
if (r.structure) return planStructure(rec, apply);
|
|
833
|
+
if (r.text) return planText(rec, apply);
|
|
834
|
+
if (r.attribute) return planAttr(rec, apply);
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const revisionNow = () => `${conflicts.hasPendingApply() ? 1 : 0}|${conflicts.list().map((r) => r.id).join(",")}`;
|
|
839
|
+
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// The whole page: what must hold after the writes
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
// How often each id and each data-id occurs, counted apart and the document element
|
|
845
|
+
// included, and which block elements sit inside a phrasing-only block or a mark.
|
|
846
|
+
// Template content counts with the page.
|
|
847
|
+
function census() {
|
|
848
|
+
const out = { ids: new Map(), nested: new Set() };
|
|
849
|
+
const tally = (el) => {
|
|
850
|
+
for (const name of ["id", "data-id"]) {
|
|
851
|
+
const value = el.getAttribute(name);
|
|
852
|
+
if (value) out.ids.set(`${name} ${value}`, (out.ids.get(`${name} ${value}`) || 0) + 1);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
const walk = (node, under) => {
|
|
856
|
+
for (const el of kidsOf(node).children) {
|
|
857
|
+
tally(el);
|
|
858
|
+
if (under && BLOCK_TAGS.has(el.tagName)) out.nested.add(el);
|
|
859
|
+
walk(el, under || isMark(el) || INLINE_ONLY_TAGS.has(el.tagName));
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
tally(document.documentElement);
|
|
863
|
+
walk(document.documentElement, false);
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// No identity doubled that was single before, no block newly inside a paragraph or
|
|
868
|
+
// a mark, and each text scope reads exactly as before with its restored spans
|
|
869
|
+
// replaced by the local text: nothing else in the scope moved.
|
|
870
|
+
function holds(plan) {
|
|
871
|
+
const after = census();
|
|
872
|
+
for (const [id, n] of after.ids) if (n > 1 && (plan.before.ids.get(id) || 0) <= 1) return false;
|
|
873
|
+
for (const el of after.nested) if (!plan.before.nested.has(el)) return false;
|
|
874
|
+
const byScope = new Map();
|
|
875
|
+
for (const op of plan.ops) if (op.kind === "text") byScope.set(op.scope, [...(byScope.get(op.scope) || []), op]);
|
|
876
|
+
for (const [scope, ops] of byScope) {
|
|
877
|
+
let expected = ops[0].before;
|
|
878
|
+
for (const op of [...ops].sort((a, b) => b.bs - a.bs)) expected = expected.slice(0, op.bs) + op.local + expected.slice(op.be);
|
|
879
|
+
const now = ops[0].owner ? scope.data : ops[0].field ? scope.value : project(scope).text;
|
|
880
|
+
if (now !== expected) return false;
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// What mutation records cannot carry back, taken before the first write: each
|
|
886
|
+
// element's attribute nodes with their values in order (qualified name, namespace and
|
|
887
|
+
// prefix travel with the node), each form control's live state, the focused element
|
|
888
|
+
// and its selection, and the document selection.
|
|
889
|
+
function remember() {
|
|
890
|
+
const attrs = new Map();
|
|
891
|
+
const walk = (root) => {
|
|
892
|
+
for (const el of root.querySelectorAll("*")) {
|
|
893
|
+
attrs.set(el, [...el.attributes].map((a) => [a, a.value]));
|
|
894
|
+
if (el.localName === "template" && el.content) walk(el.content);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
walk(document);
|
|
898
|
+
const controls = [];
|
|
899
|
+
for (const el of document.querySelectorAll("input, textarea")) controls.push([el, "value", el.value]);
|
|
900
|
+
for (const el of document.querySelectorAll("input")) controls.push([el, "checked", el.checked]);
|
|
901
|
+
for (const el of document.querySelectorAll("option")) controls.push([el, "selected", el.selected]);
|
|
902
|
+
const active = document.activeElement;
|
|
903
|
+
let caret = null;
|
|
904
|
+
try {
|
|
905
|
+
if (active && typeof active.selectionStart === "number") caret = [active.selectionStart, active.selectionEnd, active.selectionDirection];
|
|
906
|
+
} catch {}
|
|
907
|
+
const sel = document.getSelection();
|
|
908
|
+
const selection = sel && sel.rangeCount ? [sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset] : null;
|
|
909
|
+
return { attrs, controls, active, caret, selection };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function sameAttrs(el, list) {
|
|
913
|
+
const now = el.attributes;
|
|
914
|
+
return now.length === list.length && list.every(([a, value], i) => now[i] === a && a.value === value);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Every mutation record undone, last first; attributes, control state, focus and the
|
|
918
|
+
// selection then come back from the snapshot.
|
|
919
|
+
function rollback(records, page) {
|
|
920
|
+
const touched = new Set();
|
|
921
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
922
|
+
const r = records[i];
|
|
923
|
+
if (r.type === "characterData") r.target.data = r.oldValue;
|
|
924
|
+
else if (r.type === "attributes") touched.add(r.target);
|
|
925
|
+
else {
|
|
926
|
+
for (const n of r.addedNodes) if (n.parentNode === r.target) r.target.removeChild(n);
|
|
927
|
+
for (const n of r.removedNodes) r.target.insertBefore(n, r.nextSibling);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const el of touched) {
|
|
931
|
+
const list = page.attrs.get(el);
|
|
932
|
+
if (!list || sameAttrs(el, list)) continue;
|
|
933
|
+
for (const a of [...el.attributes]) el.removeAttributeNode(a);
|
|
934
|
+
for (const [a, value] of list) {
|
|
935
|
+
a.value = value;
|
|
936
|
+
el.setAttributeNode(a);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
for (const [el, prop, value] of page.controls) {
|
|
940
|
+
if (el[prop] === value) continue;
|
|
941
|
+
try {
|
|
942
|
+
el[prop] = value;
|
|
943
|
+
} catch {}
|
|
944
|
+
}
|
|
945
|
+
const sel = document.getSelection();
|
|
946
|
+
if (sel && page.selection) {
|
|
947
|
+
if (onPage(page.selection[0]) && onPage(page.selection[2])) sel.setBaseAndExtent(...page.selection);
|
|
948
|
+
} else if (sel && sel.rangeCount) sel.removeAllRanges();
|
|
949
|
+
const active = page.active;
|
|
950
|
+
if (active && active !== document.activeElement && active !== document.body && onPage(active) && active.focus) active.focus({ preventScroll: true });
|
|
951
|
+
if (page.caret && document.activeElement === active) {
|
|
952
|
+
try {
|
|
953
|
+
active.setSelectionRange(...page.caret);
|
|
954
|
+
} catch {}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Every tree a write can reach: the document and the content of every template on
|
|
959
|
+
// the page, nested templates included.
|
|
960
|
+
function observeAll(observer) {
|
|
961
|
+
const visit = (root) => {
|
|
962
|
+
observer.observe(root, OBSERVE);
|
|
963
|
+
for (const t of root.querySelectorAll("template")) if (t.content) visit(t.content);
|
|
964
|
+
};
|
|
965
|
+
visit(document);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// hyper-undo reads a text node's final data for every record on it, so a node
|
|
969
|
+
// written twice in one transaction cannot be undone through its records.
|
|
970
|
+
function undoable(records) {
|
|
971
|
+
const seen = new Set();
|
|
972
|
+
for (const r of records) {
|
|
973
|
+
if (r.type !== "characterData") continue;
|
|
974
|
+
if (seen.has(r.target)) return false;
|
|
975
|
+
seen.add(r.target);
|
|
976
|
+
}
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Read-only preflight: resolve every selected target against the page as it is,
|
|
982
|
+
* rebase text spans, settle the marks, decide which selected op each spot belongs
|
|
983
|
+
* to, and note the ledger revision and the page's census. Nothing changes here.
|
|
984
|
+
*/
|
|
985
|
+
export function prepareRevert(ids) {
|
|
986
|
+
const revision = revisionNow();
|
|
987
|
+
const blocked = new Map();
|
|
988
|
+
const covered = new Map();
|
|
989
|
+
const selected = [];
|
|
990
|
+
const seen = new Set();
|
|
991
|
+
for (const id of ids) {
|
|
992
|
+
if (seen.has(id)) continue;
|
|
993
|
+
seen.add(id);
|
|
994
|
+
const rec = conflicts.get(id);
|
|
995
|
+
if (!rec) continue;
|
|
996
|
+
if (rec.claimedBy || rec.kind === "apply-incomplete" || !rec.recovery) blocked.set(id, BLOCKED);
|
|
997
|
+
else selected.push(rec);
|
|
998
|
+
}
|
|
999
|
+
if (conflicts.hasPendingApply()) {
|
|
1000
|
+
for (const rec of selected) blocked.set(rec.id, BLOCKED);
|
|
1001
|
+
return { ops: [], covered, blocked, revision, before: null };
|
|
1002
|
+
}
|
|
1003
|
+
const constraints = [];
|
|
1004
|
+
const plan = (rec) => {
|
|
1005
|
+
const apply = conflicts.recoveryOf(rec.id);
|
|
1006
|
+
const op = apply ? planOne(rec, apply) : null;
|
|
1007
|
+
if (!op && apply) constraints.push({ ticket: apply.ticket, node: footprintOf(rec, apply) });
|
|
1008
|
+
return op;
|
|
1009
|
+
};
|
|
1010
|
+
const ops = [];
|
|
1011
|
+
for (const rec of selected) {
|
|
1012
|
+
const op = plan(rec);
|
|
1013
|
+
if (op) ops.push(op);
|
|
1014
|
+
else blocked.set(rec.id, BLOCKED);
|
|
1015
|
+
}
|
|
1016
|
+
const others = [];
|
|
1017
|
+
for (const rec of conflicts.list()) {
|
|
1018
|
+
if (seen.has(rec.id)) continue;
|
|
1019
|
+
if (rec.kind === "apply-incomplete") constraints.push({ ticket: rec.ticket, node: document.documentElement });
|
|
1020
|
+
else if (rec.recovery) {
|
|
1021
|
+
const op = plan(rec);
|
|
1022
|
+
if (op) others.push(op);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
ops.sort((a, b) => b.ticket - a.ticket);
|
|
1026
|
+
const kept = [];
|
|
1027
|
+
const shadow = [];
|
|
1028
|
+
const block = (op) => {
|
|
1029
|
+
blocked.set(op.id, BLOCKED);
|
|
1030
|
+
shadow.push(op);
|
|
1031
|
+
};
|
|
1032
|
+
for (const op of ops) {
|
|
1033
|
+
if (constraints.some((c) => c.ticket > op.ticket && touchesNode(op, c)) || others.some((o) => o.ticket > op.ticket && touch(o, op)) || shadow.some((b) => touch(b, op))) {
|
|
1034
|
+
block(op);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const newer = kept.find((k) => touch(k, op));
|
|
1038
|
+
if (!newer) {
|
|
1039
|
+
kept.push(op);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (covers(newer, op)) {
|
|
1043
|
+
covered.set(op.id, newer.id);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
block(op);
|
|
1047
|
+
// Two losses from the same merge have no order between them; neither goes.
|
|
1048
|
+
if (newer.ticket === op.ticket) {
|
|
1049
|
+
kept.splice(kept.indexOf(newer), 1);
|
|
1050
|
+
block(newer);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return { ops: kept, covered, blocked, revision, before: census() };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* One synchronous transaction over a plan: text and attributes first, then the
|
|
1058
|
+
* structural graph, then every op's postcondition and the whole-page invariants.
|
|
1059
|
+
* Returns the ids that went back, or nothing when anything failed: then every write
|
|
1060
|
+
* has been undone, the page is what it was, no id is acknowledged and nothing is
|
|
1061
|
+
* saved.
|
|
1062
|
+
*/
|
|
1063
|
+
export function applyRevert(plan) {
|
|
1064
|
+
if (plan.revision !== revisionNow()) {
|
|
1065
|
+
for (const op of plan.ops) plan.blocked.set(op.id, BLOCKED);
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
const page = remember();
|
|
1069
|
+
// The undo recorder is held while the writes run: a revert that goes through is
|
|
1070
|
+
// one entry when its records can be undone, one that is undone is none.
|
|
1071
|
+
const undo = window.clay?.undo;
|
|
1072
|
+
undo?.pause?.();
|
|
1073
|
+
const observer = new MutationObserver(() => {});
|
|
1074
|
+
const parked = document.createDocumentFragment();
|
|
1075
|
+
let records = [];
|
|
1076
|
+
let ok = false;
|
|
1077
|
+
try {
|
|
1078
|
+
observeAll(observer);
|
|
1079
|
+
observer.observe(parked, OBSERVE);
|
|
1080
|
+
const texts = plan.ops.filter((op) => op.kind === "text").sort((a, b) => (a.root === b.root ? b.s - a.s || b.e - a.e : 0));
|
|
1081
|
+
for (const op of texts) applyText(op);
|
|
1082
|
+
for (const op of plan.ops) if (op.kind === "attr") applyAttr(op);
|
|
1083
|
+
applyStructure(plan.ops.filter((op) => op.kind !== "text" && op.kind !== "attr"), parked);
|
|
1084
|
+
records = observer.takeRecords();
|
|
1085
|
+
ok = plan.ops.every((op) => op.check()) && holds(plan);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
console.error("[clay] Revert to mine failed while writing and was undone", err);
|
|
1088
|
+
} finally {
|
|
1089
|
+
records = records.concat(observer.takeRecords());
|
|
1090
|
+
observer.disconnect();
|
|
1091
|
+
}
|
|
1092
|
+
if (ok) {
|
|
1093
|
+
// A live property write is not in the records, so its entry could not undo it.
|
|
1094
|
+
if (undoable(records) && !plan.ops.some((op) => op.wroteProperty)) undo?.commitCaptured?.("Revert to mine");
|
|
1095
|
+
else undo?.discardCaptured?.();
|
|
1096
|
+
undo?.resume?.();
|
|
1097
|
+
return [...plan.ops.map((op) => op.id), ...plan.covered.keys()];
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
rollback(records, page);
|
|
1101
|
+
} finally {
|
|
1102
|
+
// The page's change feed drops the writes and their undoing together: nothing
|
|
1103
|
+
// changed, so the dirty gate, autosave and the hub's other pausable subscribers
|
|
1104
|
+
// have nothing to see.
|
|
1105
|
+
Mutation.pause();
|
|
1106
|
+
Mutation.resume();
|
|
1107
|
+
undo?.discardCaptured?.();
|
|
1108
|
+
undo?.resume?.();
|
|
1109
|
+
}
|
|
1110
|
+
for (const op of plan.ops) plan.blocked.set(op.id, BLOCKED);
|
|
1111
|
+
for (const id of plan.covered.keys()) plan.blocked.set(id, BLOCKED);
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Revert to mine for the given ids. Unknown ids are ignored; claimed ids, ids whose
|
|
1117
|
+
* apply did not finish and ids whose spot cannot be proven come back blocked and stay
|
|
1118
|
+
* open. Acknowledges after the DOM writes and before the save: the page holds the
|
|
1119
|
+
* content now, and ordinary dirty bytes protect it.
|
|
1120
|
+
*/
|
|
1121
|
+
export async function revertConflicts(ids) {
|
|
1122
|
+
// Changes made before the call reach the page's change feed first: an undone
|
|
1123
|
+
// transaction drops what the feed holds when it ends.
|
|
1124
|
+
await Promise.resolve();
|
|
1125
|
+
const plan = prepareRevert(ids);
|
|
1126
|
+
const revertedIds = applyRevert(plan);
|
|
1127
|
+
let saveResult = null;
|
|
1128
|
+
if (revertedIds.length) {
|
|
1129
|
+
gateMarkDirty();
|
|
1130
|
+
conflicts.acknowledge(revertedIds, { reason: "reverted" });
|
|
1131
|
+
markExplicitSave();
|
|
1132
|
+
// The mutation feed reports the writes above on this microtask checkpoint; the
|
|
1133
|
+
// save's gate token has to be taken after it, or the clear it earns is refused.
|
|
1134
|
+
await Promise.resolve();
|
|
1135
|
+
saveResult = await savePage();
|
|
1136
|
+
}
|
|
1137
|
+
return { revertedIds, blockedIds: [...plan.blocked.keys()], saveResult };
|
|
1138
|
+
}
|