@panphora/clayjs 1.13.1 → 1.15.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69385 -40992
- package/dist/server/checkpoint.cjs +1 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +127 -99
- package/entries/clay.js +22 -0
- package/entries/sap.js +5 -5
- package/package.json +16 -2
- package/packed-contract.json +18 -3
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +12 -3
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +154 -36
- package/src/core/snapshot.js +51 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/derived.js +116 -0
- package/src/lib/effect.js +130 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +47 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +16 -4
- package/src/loader.js +37 -1
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1170 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +386 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/derive.js +12 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +62 -4
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +36 -8
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
package/src/live/store.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Local ops change state at once. When authoritative moves, state is rebuilt in
|
|
5
5
|
// place, so an object or list page code holds a reference to stays live.
|
|
6
6
|
|
|
7
|
-
import { validateOp, checkOp, applyOp, applyOps, cloneJson, assignDeep, coalesce, serializeLive } from "./ops.js";
|
|
7
|
+
import { validateOp, checkOp, applyOp, applyOps, applyTracked, undoTracked, cloneJson, assignDeep, coalesce, overlaps, serializeLive } from "./ops.js";
|
|
8
8
|
|
|
9
9
|
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
|
|
10
10
|
const INDEX = /^(0|[1-9]\d*)$/;
|
|
@@ -21,6 +21,79 @@ function readPath(root, path) {
|
|
|
21
21
|
return node;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
25
|
+
const sameOp = (a, b) => serializeLive(a) === serializeLive(b);
|
|
26
|
+
|
|
27
|
+
// Applies ops in place, all or none: the records of what changed, or null
|
|
28
|
+
// (and root as it was) when one does not apply.
|
|
29
|
+
function track(root, ops) {
|
|
30
|
+
const done = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const op of ops) {
|
|
33
|
+
const rec = applyTracked(root, op);
|
|
34
|
+
if (rec) {
|
|
35
|
+
done.push(rec);
|
|
36
|
+
root = rec.root;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return done;
|
|
40
|
+
} catch {
|
|
41
|
+
untrack(done);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function untrack(done) {
|
|
47
|
+
let root;
|
|
48
|
+
for (let i = done.length - 1; i >= 0; i--) root = undoTracked(done[i]);
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// What a frame's ops did, slot by slot, when page state can follow them one
|
|
53
|
+
// slot at a time instead of a rebuild: each object slot the frame changed once
|
|
54
|
+
// or more (path, whether it held a value before and its value then), none
|
|
55
|
+
// inside another, no list changed at its own level, no whole value replaced,
|
|
56
|
+
// and no key taken out and put back (that moves the key to the end). Pending
|
|
57
|
+
// ops this tab has not seen confirmed must not touch those slots, nor add or
|
|
58
|
+
// remove keys beside a key the frame adds. With each slot, the object in state
|
|
59
|
+
// that mirrors it (into). null: rebuild.
|
|
60
|
+
function slotsOf(done, pending, state) {
|
|
61
|
+
const slots = [];
|
|
62
|
+
for (const rec of done) {
|
|
63
|
+
if (rec.at === null || Array.isArray(rec.at)) return null;
|
|
64
|
+
const slot = slots.find((x) => x.path.length === rec.path.length && overlaps(x.path, rec.path));
|
|
65
|
+
if (slot) {
|
|
66
|
+
// Taken out and put back in one frame: the key moves to the end.
|
|
67
|
+
if (slot.removed && rec.kind !== "remove") return null;
|
|
68
|
+
if (rec.kind === "remove") slot.removed = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (slots.some((x) => overlaps(x.path, rec.path))) return null;
|
|
72
|
+
slots.push({ path: rec.path, at: rec.at, key: rec.key, had: rec.kind !== "add", old: rec.old, removed: rec.kind === "remove" });
|
|
73
|
+
}
|
|
74
|
+
for (const slot of slots) {
|
|
75
|
+
slot.has = has(slot.at, slot.key);
|
|
76
|
+
if (slot.had && slot.has && slot.removed) return null;
|
|
77
|
+
slot.into = readPath(state, slot.path.slice(0, -1));
|
|
78
|
+
if (!isContainer(slot.into) || Array.isArray(slot.into) || has(slot.into, slot.key) !== slot.had) return null;
|
|
79
|
+
const parent = slot.path.slice(0, -1);
|
|
80
|
+
for (const entry of pending) {
|
|
81
|
+
const path = entry.op.path;
|
|
82
|
+
if (overlaps(slot.path, path)) return null;
|
|
83
|
+
// A pending delete in a list above the slot shifts the indices the
|
|
84
|
+
// frame's path counts in.
|
|
85
|
+
if (entry.op.op === "del" && path.length <= slot.path.length && overlaps(path.slice(0, -1), slot.path) && Array.isArray(readPath(state, path.slice(0, -1)))) return null;
|
|
86
|
+
// A key the frame adds goes last; a pending op that adds or takes out a
|
|
87
|
+
// key of the same object would order them differently in a rebuild.
|
|
88
|
+
if (!slot.had && slot.has && path.length > parent.length && overlaps(parent, path)) {
|
|
89
|
+
const child = path[parent.length];
|
|
90
|
+
if (path.length === parent.length + 1 ? entry.op.op === "del" || !has(slot.at, String(child)) : !has(slot.at, String(child))) return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return slots;
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
// applyOp, except that a set replacing a container with one of the same kind
|
|
25
98
|
// rewrites it in place, so a proxy over the old container still points at the
|
|
26
99
|
// value page code sees.
|
|
@@ -40,6 +113,9 @@ export function createStore({ name, value, ephemeral = false }) {
|
|
|
40
113
|
|
|
41
114
|
let authoritative = cloneJson(value);
|
|
42
115
|
const state = cloneJson(value);
|
|
116
|
+
// state is authoritative with pending replayed over it. Only a confirm whose
|
|
117
|
+
// ops no longer apply breaks that, until the next rebuild.
|
|
118
|
+
let coherent = true;
|
|
43
119
|
// This tab's unconfirmed ops, oldest first, each with a local id that only grows.
|
|
44
120
|
let pending = [];
|
|
45
121
|
let nextId = 1;
|
|
@@ -48,6 +124,7 @@ export function createStore({ name, value, ephemeral = false }) {
|
|
|
48
124
|
const listeners = new Set();
|
|
49
125
|
const sinks = new Set();
|
|
50
126
|
const rebases = new Set();
|
|
127
|
+
const confirmations = new Set();
|
|
51
128
|
const deliveries = new Set();
|
|
52
129
|
const views = new WeakMap();
|
|
53
130
|
|
|
@@ -74,6 +151,47 @@ export function createStore({ name, value, ephemeral = false }) {
|
|
|
74
151
|
}
|
|
75
152
|
pending = kept;
|
|
76
153
|
assignDeep(state, next);
|
|
154
|
+
coherent = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tell(event) {
|
|
158
|
+
for (const fn of [...rebases]) {
|
|
159
|
+
try {
|
|
160
|
+
fn(event);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`clay.live("${name}"): a rebase listener threw`, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// drop: [first, last], the pending ops with ids in that range are taken out
|
|
168
|
+
// (an op the host refused); the others stay.
|
|
169
|
+
function rebase(next, { source = "remote", confirmed = 0, through, drop, dropIds, frame = null, room = null, cause = null } = {}) {
|
|
170
|
+
if (!isContainer(next) || kind(next) !== kind(state)) {
|
|
171
|
+
console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const before = serializeLive(state);
|
|
175
|
+
const prev = authoritative;
|
|
176
|
+
authoritative = cloneJson(next);
|
|
177
|
+
if (drop) pending = pending.filter((entry) => entry.id < drop[0] || entry.id > drop[1]);
|
|
178
|
+
else if (dropIds) { const ids = new Set(dropIds); pending = pending.filter((entry) => !ids.has(entry.id)); }
|
|
179
|
+
else pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
|
|
180
|
+
rebuild();
|
|
181
|
+
tell({ source, frame, room, cause, prev, before: (path) => readPath(prev, path), next: authoritative });
|
|
182
|
+
if (serializeLive(state) !== before) emit(source, null);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The value before the frame whose records are done: authoritative copied,
|
|
187
|
+
// with the records taken back in the copy, path by path.
|
|
188
|
+
function priorOf(done) {
|
|
189
|
+
const copy = cloneJson(authoritative);
|
|
190
|
+
for (let i = done.length - 1; i >= 0; i--) {
|
|
191
|
+
const rec = done[i];
|
|
192
|
+
undoTracked({ ...rec, at: readPath(copy, rec.path.slice(0, -1)) });
|
|
193
|
+
}
|
|
194
|
+
return copy;
|
|
77
195
|
}
|
|
78
196
|
|
|
79
197
|
function publish(ops) {
|
|
@@ -238,6 +356,8 @@ export function createStore({ name, value, ephemeral = false }) {
|
|
|
238
356
|
get value() {
|
|
239
357
|
return state;
|
|
240
358
|
},
|
|
359
|
+
// The room's value. A frame changes it in place (rebaseOps), so a caller
|
|
360
|
+
// that keeps any part of it past the call copies it. Never mutate it.
|
|
241
361
|
get authoritative() {
|
|
242
362
|
return authoritative;
|
|
243
363
|
},
|
|
@@ -270,31 +390,89 @@ export function createStore({ name, value, ephemeral = false }) {
|
|
|
270
390
|
authoritative = applyOps(cloneJson(authoritative), taken.map((entry) => entry.op));
|
|
271
391
|
} catch {
|
|
272
392
|
authoritative = cloneJson(state);
|
|
393
|
+
coherent = false;
|
|
273
394
|
}
|
|
274
395
|
pending = pending.filter((entry) => entry.id > through);
|
|
396
|
+
for (const fn of [...confirmations]) {
|
|
397
|
+
try {
|
|
398
|
+
fn({ through });
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.error(`clay.live("${name}"): a confirmation listener threw`, err);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
onConfirm(fn) {
|
|
405
|
+
confirmations.add(fn);
|
|
406
|
+
return () => confirmations.delete(fn);
|
|
275
407
|
},
|
|
276
408
|
// A new value from outside. Ops already in it are dropped: the first
|
|
277
409
|
// `confirmed` pending ops, or every op up to the mark `through`. The rest
|
|
278
410
|
// replay over it; a pending op that no longer applies is dropped too.
|
|
279
411
|
// Listeners hear about it only when the value page code sees changed.
|
|
280
|
-
rebase
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
412
|
+
rebase,
|
|
413
|
+
// The ops of a frame the room ordered, checked: the same ops validated, or
|
|
414
|
+
// null when they do not apply to authoritative as a whole.
|
|
415
|
+
checkOps(ops) {
|
|
416
|
+
let checked;
|
|
417
|
+
try {
|
|
418
|
+
checked = ops.map(validateOp);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
284
421
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
422
|
+
const done = track(authoritative, checked);
|
|
423
|
+
if (!done) return null;
|
|
424
|
+
untrack(done);
|
|
425
|
+
return checked;
|
|
426
|
+
},
|
|
427
|
+
// rebase(authoritative with checked ops applied, options), without copying
|
|
428
|
+
// the whole value: the ops change authoritative in place, and page state
|
|
429
|
+
// follows slot by slot when slotsOf allows, or does not move when the ops
|
|
430
|
+
// are this tab's own pending ops coming back. Anything else rebuilds, as
|
|
431
|
+
// rebase does. Listeners get prev only when they read it, and before(path)
|
|
432
|
+
// for what one path held. next is authoritative itself, changed in place by
|
|
433
|
+
// later frames: a listener copies what it keeps; prev is its own copy.
|
|
434
|
+
rebaseOps(ops, { source = "remote", confirmed = 0, through, frame = null, room = null, cause = null } = {}) {
|
|
435
|
+
const done = track(authoritative, ops);
|
|
436
|
+
if (!done) return false;
|
|
437
|
+
const kept = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
|
|
438
|
+
const dropped = pending.length - kept.length;
|
|
439
|
+
const whole = done.some((rec) => rec.at === null);
|
|
440
|
+
const own = coherent && !whole && dropped === ops.length && ops.every((op, i) => sameOp(op, pending[i].op));
|
|
441
|
+
const slots = coherent && !own && dropped === 0 ? slotsOf(done, kept, state) : null;
|
|
442
|
+
if (!own && !slots) {
|
|
443
|
+
untrack(done);
|
|
444
|
+
return rebase(applyOps(cloneJson(authoritative), ops), { source, confirmed, through, frame, room, cause });
|
|
445
|
+
}
|
|
446
|
+
pending = kept;
|
|
447
|
+
let changed = false;
|
|
448
|
+
for (const slot of slots ?? []) {
|
|
449
|
+
const at = slot.into;
|
|
450
|
+
if (slot.has) {
|
|
451
|
+
const value = slot.at[slot.key];
|
|
452
|
+
if (!slot.had || serializeLive(slot.old) !== serializeLive(value)) changed = true;
|
|
453
|
+
const cur = slot.had ? at[slot.key] : undefined;
|
|
454
|
+
const next = cloneJson(value);
|
|
455
|
+
const same = isContainer(cur) && isContainer(next) && Array.isArray(cur) === Array.isArray(next);
|
|
456
|
+
Object.defineProperty(at, slot.key, { value: same ? assignDeep(cur, next) : next, enumerable: true, writable: true, configurable: true });
|
|
457
|
+
} else if (slot.had) {
|
|
458
|
+
changed = true;
|
|
459
|
+
delete at[slot.key];
|
|
295
460
|
}
|
|
296
461
|
}
|
|
297
|
-
|
|
462
|
+
let prior = null;
|
|
463
|
+
const prev = () => (prior ??= priorOf(done));
|
|
464
|
+
const shifted = done.some((rec) => Array.isArray(rec.at) && rec.kind !== "put");
|
|
465
|
+
const before = (path) => {
|
|
466
|
+
if (shifted) return readPath(prev(), path);
|
|
467
|
+
for (const rec of done) {
|
|
468
|
+
if (!overlaps(rec.path, path)) continue;
|
|
469
|
+
if (rec.path.length > path.length) return readPath(prev(), path);
|
|
470
|
+
return readPath(rec.old, path.slice(rec.path.length));
|
|
471
|
+
}
|
|
472
|
+
return readPath(authoritative, path);
|
|
473
|
+
};
|
|
474
|
+
tell({ source, frame, room, cause, get prev() { return prev(); }, before, next: authoritative });
|
|
475
|
+
if (changed) emit(source, null);
|
|
298
476
|
return true;
|
|
299
477
|
},
|
|
300
478
|
// Every rebase, whether or not the value page code sees changed, with what
|
package/src/loader-logic.js
CHANGED
|
@@ -31,6 +31,7 @@ export const PLUGIN_PATHS = {
|
|
|
31
31
|
// A live JSON tag as an in-memory object (clay.live). Not editOnly: a reader's
|
|
32
32
|
// tab still shows the game, and its writes stay in that tab.
|
|
33
33
|
live: { path: "plugins/live.js", editOnly: false, default: false },
|
|
34
|
+
page: { path: "plugins/page.js", editOnly: true, default: false },
|
|
34
35
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
35
36
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
36
37
|
cms: { path: "plugins/cms.js", editOnly: false, default: false },
|
|
@@ -59,6 +60,9 @@ export const PLUGIN_PATHS = {
|
|
|
59
60
|
// it replaces. `exclude=source` turns it off.
|
|
60
61
|
source: { path: "plugins/source.js", editOnly: true, default: true },
|
|
61
62
|
"visitor-drafts": { path: "plugins/visitor-drafts.js", editOnly: false, default: false },
|
|
63
|
+
// The sapjs core. Loaded for any page with a [sap] root at boot (resolveModules'
|
|
64
|
+
// hasSap), in view mode too: a reader's tab paints the same totals.
|
|
65
|
+
derive: { path: "plugins/derive.js", editOnly: false, default: false },
|
|
62
66
|
};
|
|
63
67
|
|
|
64
68
|
// One literal import per module the loader can ask for, so a bundler can see the
|
|
@@ -96,6 +100,7 @@ export const MODULES = {
|
|
|
96
100
|
"plugins/indicator.js": () => import("./plugins/indicator.js"),
|
|
97
101
|
"sync/live-sync.js": () => import("./sync/live-sync.js"),
|
|
98
102
|
"plugins/live.js": () => import("./plugins/live.js"),
|
|
103
|
+
"plugins/page.js": () => import("./plugins/page.js"),
|
|
99
104
|
"plugins/sortable.js": () => import("./plugins/sortable.js"),
|
|
100
105
|
"plugins/undo.js": () => import("./plugins/undo.js"),
|
|
101
106
|
"plugins/cms.js": () => import("./plugins/cms.js"),
|
|
@@ -108,11 +113,12 @@ export const MODULES = {
|
|
|
108
113
|
"plugins/demo.js": () => import("./plugins/demo.js"),
|
|
109
114
|
"plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
|
|
110
115
|
"plugins/source.js": () => import("./plugins/source.js"),
|
|
116
|
+
"plugins/derive.js": () => import("./plugins/derive.js"),
|
|
111
117
|
};
|
|
112
118
|
|
|
113
119
|
// `source` is last on purpose: its install captures a save clone, so it wants every
|
|
114
120
|
// plugin that registers a document transform to have registered it first.
|
|
115
|
-
const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "message", "wire", "ai-edit", "demo", "source"];
|
|
121
|
+
const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "page", "message", "wire", "ai-edit", "demo", "derive", "source"];
|
|
116
122
|
|
|
117
123
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
118
124
|
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
@@ -134,7 +140,12 @@ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "un
|
|
|
134
140
|
// that asks for it has to have the wire. The wire loads first in the order above.
|
|
135
141
|
// It does not bring undo: hyper-undo takes Cmd+Z for the whole window, text fields
|
|
136
142
|
// included, which a page that never asked for it should not get by default.
|
|
137
|
-
|
|
143
|
+
// A page's peers reach it through live sync's stream (live/stream-bridge.js), so
|
|
144
|
+
// a Document without sync saves whole files and never sees another tab type. Its
|
|
145
|
+
// image and file blocks send through `clay.upload`: without the plugin every drop
|
|
146
|
+
// ends in "File uploads are not available", while with it a host that stores no
|
|
147
|
+
// files answers `unsupported` and the editor offers to embed the file instead.
|
|
148
|
+
const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"], page: ["live", "sync", "upload"] };
|
|
138
149
|
|
|
139
150
|
function parseCsv(params, key, enabled, apply) {
|
|
140
151
|
const raw = params.get(key);
|
|
@@ -152,13 +163,13 @@ function parseCsv(params, key, enabled, apply) {
|
|
|
152
163
|
|
|
153
164
|
// Each of these talks to the host or writes outside the visitor's own record, so a
|
|
154
165
|
// page that keeps its edits in this browser cannot carry them.
|
|
155
|
-
const DRAFT_DROPS = ["sync", "live", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
|
|
166
|
+
const DRAFT_DROPS = ["sync", "live", "page", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
|
|
156
167
|
|
|
157
168
|
// [refetch-on-save] swaps its element for a fresh GET of the same URL after every
|
|
158
169
|
// save, which on a visitor's own draft is a request to the host.
|
|
159
170
|
const DRAFT_DROPS_CORE = ["attrs/refetch-on-save.js"];
|
|
160
171
|
|
|
161
|
-
export function resolveModules(params, isEditMode, { isDraftMode = false } = {}) {
|
|
172
|
+
export function resolveModules(params, isEditMode, { isDraftMode = false, hasSap = false } = {}) {
|
|
162
173
|
const core = [...CORE_WAVES.always];
|
|
163
174
|
if (isEditMode) core.push(...CORE_WAVES.editOnly);
|
|
164
175
|
|
|
@@ -171,6 +182,7 @@ export function resolveModules(params, isEditMode, { isDraftMode = false } = {})
|
|
|
171
182
|
for (const [name, spec] of Object.entries(PLUGIN_PATHS)) {
|
|
172
183
|
if (spec.default) enabled.add(name);
|
|
173
184
|
}
|
|
185
|
+
if (hasSap) enabled.add("derive");
|
|
174
186
|
// ai-edit cannot run without the wire, so excluding the wire excludes it too.
|
|
175
187
|
if (excluded.has("wire")) enabled.delete("ai-edit");
|
|
176
188
|
// A default-on plugin that this mode drops brings nothing with it. An explicitly
|
package/src/loader.js
CHANGED
|
@@ -2,6 +2,10 @@ import { resolveModules, MODULES } from "./loader-logic.js";
|
|
|
2
2
|
import onDomReady from "./lib/dom-ready.js";
|
|
3
3
|
import { initTheme } from "./ui/theme.js";
|
|
4
4
|
import { ephemeral } from "./lib/ephemeral.js";
|
|
5
|
+
import { effect } from "./lib/effect.js";
|
|
6
|
+
import { style } from "./lib/style.js";
|
|
7
|
+
import { derived } from "./lib/derived.js";
|
|
8
|
+
import { bootPhase } from "./core/boot-phase.js";
|
|
5
9
|
|
|
6
10
|
function domReady() {
|
|
7
11
|
return new Promise((resolve) => onDomReady(resolve));
|
|
@@ -34,7 +38,9 @@ export async function boot(base, params, readyResolve) {
|
|
|
34
38
|
|
|
35
39
|
const { isDraftMode } = editMode;
|
|
36
40
|
|
|
37
|
-
const
|
|
41
|
+
const hasSap = Boolean(document.querySelector("[sap]"));
|
|
42
|
+
bootPhase.holdSettle = hasSap && isEditMode;
|
|
43
|
+
const plan = resolveModules(params, isEditMode, { isDraftMode, hasSap });
|
|
38
44
|
const loaded = {};
|
|
39
45
|
|
|
40
46
|
for (const path of plan.core) {
|
|
@@ -68,11 +74,36 @@ export async function boot(base, params, readyResolve) {
|
|
|
68
74
|
// clay.ready until they finish. allSettled: a failed plugin degrades, never blocks boot.
|
|
69
75
|
await Promise.allSettled(plan.plugins.map((path) => loaded[path]?.ready).filter(Boolean));
|
|
70
76
|
|
|
77
|
+
if (hasSap) mountDerivedEngine(loaded);
|
|
78
|
+
|
|
71
79
|
installViewModeNotice(isEditMode);
|
|
72
80
|
readyResolve(window.clay);
|
|
73
81
|
document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
// The explicit mount phase for a derive engine (sapjs or its core). It runs after
|
|
85
|
+
// frozen regions recorded their authored markup (save-freeze, core wave) and
|
|
86
|
+
// visitor drafts were restored (plugins), so the first pass paints the page those
|
|
87
|
+
// produced, and the held boot baseline settles on that painted page. Sap.mount()
|
|
88
|
+
// skips roots already mounted, so sapjs's own later mount at clay.ready is a no-op.
|
|
89
|
+
export function mountDerivedEngine(loaded) {
|
|
90
|
+
const save = loaded["core/save.js"];
|
|
91
|
+
let fileSignature = null;
|
|
92
|
+
try {
|
|
93
|
+
fileSignature = save ? save.currentPaintSignature() : null;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error("clayjs: could not read the page's painted values:", err);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
if (window.Sap && typeof window.Sap.mount === "function") window.Sap.mount();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error("clayjs: the derive engine failed to mount:", err);
|
|
101
|
+
} finally {
|
|
102
|
+
save?.releaseBootSettle();
|
|
103
|
+
}
|
|
104
|
+
if (save && fileSignature != null) save.schedulePaintHeal(fileSignature);
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy) {
|
|
77
108
|
const clay = window.clay;
|
|
78
109
|
|
|
@@ -98,6 +129,9 @@ function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility
|
|
|
98
129
|
clay.readData = pageData.readData;
|
|
99
130
|
clay.writeData = pageData.writeData;
|
|
100
131
|
clay.ephemeral = ephemeral;
|
|
132
|
+
clay.derived = derived;
|
|
133
|
+
clay.effect = effect;
|
|
134
|
+
clay.style = style;
|
|
101
135
|
|
|
102
136
|
if (save) {
|
|
103
137
|
const saveFn = save.savePage || save.default;
|
|
@@ -133,6 +167,8 @@ function attachPluginMember(path, mod, loaded) {
|
|
|
133
167
|
loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
|
|
134
168
|
} else if (path === "plugins/live.js") {
|
|
135
169
|
clay.live = mod.live;
|
|
170
|
+
} else if (path === "plugins/page.js") {
|
|
171
|
+
clay.page = mod.page;
|
|
136
172
|
} else if (path === "plugins/cms.js") {
|
|
137
173
|
clay.cms = mod.cms || mod.default;
|
|
138
174
|
} else if (path === "plugins/upload.js") {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
|
|
2
|
+
|
|
3
|
+
export function safeHref(raw) {
|
|
4
|
+
const s = String(raw ?? "").trim().replace(/[\u0000-\u001f\u007f]/g, "");
|
|
5
|
+
if (!s) return null;
|
|
6
|
+
if (s.startsWith("#") || (s.startsWith("/") && !s.startsWith("//"))) return s;
|
|
7
|
+
if (SAFE_SCHEME.test(s)) return s;
|
|
8
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return null;
|
|
9
|
+
if (/^(\/\/)?[\w-]+(\.[\w-]+)+(:\d+)?([/?#]\S*)?$/.test(s)) return `https://${s.replace(/^\/\//, "")}`;
|
|
10
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return `mailto:${s}`;
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const safeSrc = (s) => (s && /^data:image\/(png|jpeg|gif|webp);/i.test(s) && s.length <= 2_100_000 ? s : safeHref(s));
|
|
15
|
+
|
|
16
|
+
export function safeFileHref(raw) {
|
|
17
|
+
if (typeof raw !== "string") return null;
|
|
18
|
+
const href = safeHref(raw);
|
|
19
|
+
return href && /^(https?:|\/(?!\/))/i.test(href) ? href : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// When a hand-edited article is adopted: the file read at boot, before the
|
|
2
|
+
// editor mounts into the article, and every newer file that arrives. Only the
|
|
3
|
+
// checkpoint writer adopts (two adopters would both land the same splice). While
|
|
4
|
+
// an adoption waits for the binding or an IME composition, autosave is held, so
|
|
5
|
+
// a checkpoint cannot save the old article over the edit first.
|
|
6
|
+
import { suspendAutosave, resumeAutosave } from "../core/save.js";
|
|
7
|
+
import { history } from "../vendor/prosemirror.vendor.js";
|
|
8
|
+
import { ADOPT, classifyFile, planAdoption, adoptionTransaction, readBack } from "./adopt.js";
|
|
9
|
+
import { mergePageModels } from "./merge.js";
|
|
10
|
+
import { blockMap } from "./model.js";
|
|
11
|
+
import { parseModel } from "./projection.js";
|
|
12
|
+
import { ARTICLE, pageTags } from "./save-projection.js";
|
|
13
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
14
|
+
|
|
15
|
+
const parseTag = (text) => { try { return HyperMorph.parseJsonRelaxed(text); } catch { return null; } };
|
|
16
|
+
|
|
17
|
+
export function readFile(root) {
|
|
18
|
+
const article = root.querySelector(ARTICLE);
|
|
19
|
+
return article ? { article: article.cloneNode(true), tags: pageTags(root).map((el) => parseTag(el.textContent)) } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compositionIdle(view) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
if (!view.composing) return resolve();
|
|
25
|
+
view.dom.addEventListener("compositionend", () => setTimeout(() => resolve(compositionIdle(view)), 0), { once: true });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installAdoption({
|
|
30
|
+
view,
|
|
31
|
+
source = null,
|
|
32
|
+
ready = Promise.resolve(),
|
|
33
|
+
isAdopter = () => true,
|
|
34
|
+
idle = () => compositionIdle(view),
|
|
35
|
+
requestCheckpoint = () => {},
|
|
36
|
+
notice = () => {},
|
|
37
|
+
snapshot = () => null,
|
|
38
|
+
mergeTagless = false,
|
|
39
|
+
writerReady = () => Promise.resolve(),
|
|
40
|
+
adopted = () => {},
|
|
41
|
+
hold = suspendAutosave,
|
|
42
|
+
release = resumeAutosave,
|
|
43
|
+
holdMs = 10_000,
|
|
44
|
+
doc = document,
|
|
45
|
+
}) {
|
|
46
|
+
let last = source ? source.article.innerHTML : null;
|
|
47
|
+
// A host that keeps no page data (HTML Clay) sends files with no page tag. What
|
|
48
|
+
// this tab saw on disk per data-rev, in the order it saw it: the boot article,
|
|
49
|
+
// every frame, and every article this tab's own save sent. The base for a frame
|
|
50
|
+
// is the last of those with its rev, else the last article on disk: an untouched
|
|
51
|
+
// older copy then merges to nothing, and a hand edit that kept the rev merges in.
|
|
52
|
+
let disk = source ? source.article : null;
|
|
53
|
+
const seen = new Map();
|
|
54
|
+
const record = (article) => {
|
|
55
|
+
const rev = article.getAttribute("data-rev");
|
|
56
|
+
if (!rev) return;
|
|
57
|
+
const list = seen.get(rev) ?? [];
|
|
58
|
+
seen.delete(rev);
|
|
59
|
+
seen.set(rev, list);
|
|
60
|
+
list.push(article);
|
|
61
|
+
if (list.length > 10) list.shift();
|
|
62
|
+
if (seen.size > 20) seen.delete(seen.keys().next().value);
|
|
63
|
+
};
|
|
64
|
+
if (disk) record(disk);
|
|
65
|
+
let queue = Promise.resolve();
|
|
66
|
+
let disposed = false;
|
|
67
|
+
let cancel;
|
|
68
|
+
const cancelled = new Promise((resolve) => (cancel = resolve));
|
|
69
|
+
|
|
70
|
+
// The hold never outlives the controller, and never lasts past holdMs: an
|
|
71
|
+
// editor that never settles must not stop every later save.
|
|
72
|
+
async function waitForEditor() {
|
|
73
|
+
let timer;
|
|
74
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), holdMs); });
|
|
75
|
+
try {
|
|
76
|
+
return await Promise.race([Promise.resolve(ready).then(() => idle()).then(() => "ready"), cancelled.then(() => "disposed"), timeout]);
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The editor merged three-way with what the file changed since base.
|
|
83
|
+
function land(blocks, current, baseItems, incoming) {
|
|
84
|
+
const merged = mergePageModels(baseItems, current, incoming, { doc, merge3: HyperMorph.merge3, mergeJson: HyperMorph.mergeJson });
|
|
85
|
+
const lost = merged.conflicts.filter((k) => k.kind === "text" || k.kind === "field");
|
|
86
|
+
const copy = lost.length ? snapshot() : null;
|
|
87
|
+
const plan = planAdoption(blocks, merged.items, current);
|
|
88
|
+
const tr = plan && adoptionTransaction(view.state, plan);
|
|
89
|
+
if (!tr) return null;
|
|
90
|
+
view.dispatch(tr);
|
|
91
|
+
view.dispatch(history.closeHistory(view.state.tr));
|
|
92
|
+
const counts = { edited: plan.edited.size, created: plan.created.size, deleted: plan.deleted.length, moved: plan.reordered };
|
|
93
|
+
const { displaced } = tr.getMeta(ADOPT);
|
|
94
|
+
if (Object.keys(displaced).length) counts.displaced = displaced;
|
|
95
|
+
if (lost.length) counts.conflicts = lost;
|
|
96
|
+
if (copy) counts.copy = copy;
|
|
97
|
+
return counts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function adopt(file) {
|
|
101
|
+
if (mergeTagless && !file.tags.length) return adoptTagless(file);
|
|
102
|
+
const found = classifyFile(file.article, file.tags);
|
|
103
|
+
if (found.kind === "unbased") console.warn("page: the article and its data changed together; kept the data");
|
|
104
|
+
if (found.kind !== "adopt" || !isAdopter()) return null;
|
|
105
|
+
hold();
|
|
106
|
+
try {
|
|
107
|
+
const waited = await waitForEditor();
|
|
108
|
+
if (waited === "timeout") console.warn("page: gave up waiting for the editor; the hand edit was not adopted");
|
|
109
|
+
if (waited !== "ready" || disposed || !isAdopter()) return null;
|
|
110
|
+
const blocks = blockMap(view.state.doc);
|
|
111
|
+
const counts = land(blocks, readBack(blocks, doc), found.baseItems, found.parsed);
|
|
112
|
+
if (counts) adopted(found.A);
|
|
113
|
+
return counts;
|
|
114
|
+
} finally {
|
|
115
|
+
release();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function adoptTagless(file) {
|
|
120
|
+
const rev = file.article.getAttribute("data-rev");
|
|
121
|
+
const list = (rev && seen.get(rev)) || [];
|
|
122
|
+
const base = list.at(-1) ?? disk;
|
|
123
|
+
// An untouched older copy leaves the page as it is.
|
|
124
|
+
if (list.slice(0, -1).some((article) => article.innerHTML === file.article.innerHTML)) return null;
|
|
125
|
+
record(file.article);
|
|
126
|
+
disk = file.article;
|
|
127
|
+
if (!base) return null;
|
|
128
|
+
// Only the room's elected writer merges a frame, so it lands once however
|
|
129
|
+
// many tabs see it; and it lands even when the editor never settles, since
|
|
130
|
+
// the next save would otherwise write over the edit.
|
|
131
|
+
hold();
|
|
132
|
+
try {
|
|
133
|
+
await writerReady();
|
|
134
|
+
if (disposed || !isAdopter()) return null;
|
|
135
|
+
const waited = await waitForEditor();
|
|
136
|
+
if (waited === "disposed" || disposed || !isAdopter()) return null;
|
|
137
|
+
const blocks = blockMap(view.state.doc);
|
|
138
|
+
const current = readBack(blocks, doc);
|
|
139
|
+
const known = new Map(current.map((item) => [item.id, item]));
|
|
140
|
+
const parse = (article) => parseModel(article, { seed: article.getAttribute("data-rev") ?? "", known });
|
|
141
|
+
return land(blocks, current, parse(base), parse(file.article));
|
|
142
|
+
} finally {
|
|
143
|
+
release();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function run(file) {
|
|
148
|
+
queue = queue
|
|
149
|
+
.then(() => adopt(file))
|
|
150
|
+
.then((counts) => {
|
|
151
|
+
if (!counts) return;
|
|
152
|
+
requestCheckpoint();
|
|
153
|
+
notice(counts);
|
|
154
|
+
doc.dispatchEvent(new CustomEvent("clay:page-adopted", { detail: counts }));
|
|
155
|
+
})
|
|
156
|
+
.catch((error) => console.error("page: adoption failed", error));
|
|
157
|
+
return queue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onFrame(event) {
|
|
161
|
+
const html = event.detail?.html;
|
|
162
|
+
if (disposed || typeof html !== "string") return;
|
|
163
|
+
const file = readFile(new DOMParser().parseFromString(html, "text/html"));
|
|
164
|
+
if (!file || file.article.innerHTML === last) return;
|
|
165
|
+
last = file.article.innerHTML;
|
|
166
|
+
run(file);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
doc.addEventListener("clay:sync-applied", onFrame);
|
|
170
|
+
if (source) run(source);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
idle: () => queue,
|
|
174
|
+
// A save of this tab landed: the file holds what it sent, so that article is
|
|
175
|
+
// the one an outside edit made on the saved file merges against.
|
|
176
|
+
saved(html) {
|
|
177
|
+
if (!mergeTagless || typeof html !== "string") return;
|
|
178
|
+
const article = new DOMParser().parseFromString(html, "text/html").querySelector(ARTICLE);
|
|
179
|
+
if (!article) return;
|
|
180
|
+
record(article);
|
|
181
|
+
disk = article;
|
|
182
|
+
},
|
|
183
|
+
dispose() {
|
|
184
|
+
disposed = true;
|
|
185
|
+
cancel();
|
|
186
|
+
doc.removeEventListener("clay:sync-applied", onFrame);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|