@panphora/clayjs 1.13.1 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69667 -41708
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +94 -93
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- 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 +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -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 +7 -3
- package/src/loader.js +6 -0
- 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 +1121 -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 +384 -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/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- 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/live-sync.js +13 -2
- 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/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 },
|
|
@@ -96,6 +97,7 @@ export const MODULES = {
|
|
|
96
97
|
"plugins/indicator.js": () => import("./plugins/indicator.js"),
|
|
97
98
|
"sync/live-sync.js": () => import("./sync/live-sync.js"),
|
|
98
99
|
"plugins/live.js": () => import("./plugins/live.js"),
|
|
100
|
+
"plugins/page.js": () => import("./plugins/page.js"),
|
|
99
101
|
"plugins/sortable.js": () => import("./plugins/sortable.js"),
|
|
100
102
|
"plugins/undo.js": () => import("./plugins/undo.js"),
|
|
101
103
|
"plugins/cms.js": () => import("./plugins/cms.js"),
|
|
@@ -112,7 +114,7 @@ export const MODULES = {
|
|
|
112
114
|
|
|
113
115
|
// `source` is last on purpose: its install captures a save clone, so it wants every
|
|
114
116
|
// 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"];
|
|
117
|
+
const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "page", "message", "wire", "ai-edit", "demo", "source"];
|
|
116
118
|
|
|
117
119
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
118
120
|
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
@@ -134,7 +136,9 @@ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "un
|
|
|
134
136
|
// that asks for it has to have the wire. The wire loads first in the order above.
|
|
135
137
|
// It does not bring undo: hyper-undo takes Cmd+Z for the whole window, text fields
|
|
136
138
|
// included, which a page that never asked for it should not get by default.
|
|
137
|
-
|
|
139
|
+
// A page's peers reach it through live sync's stream (live/stream-bridge.js), so
|
|
140
|
+
// a Document without sync saves whole files and never sees another tab type.
|
|
141
|
+
const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"], page: ["live", "sync"] };
|
|
138
142
|
|
|
139
143
|
function parseCsv(params, key, enabled, apply) {
|
|
140
144
|
const raw = params.get(key);
|
|
@@ -152,7 +156,7 @@ function parseCsv(params, key, enabled, apply) {
|
|
|
152
156
|
|
|
153
157
|
// Each of these talks to the host or writes outside the visitor's own record, so a
|
|
154
158
|
// 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"];
|
|
159
|
+
const DRAFT_DROPS = ["sync", "live", "page", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
|
|
156
160
|
|
|
157
161
|
// [refetch-on-save] swaps its element for a fresh GET of the same URL after every
|
|
158
162
|
// save, which on a visitor's own draft is a request to the host.
|
package/src/loader.js
CHANGED
|
@@ -2,6 +2,8 @@ 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";
|
|
5
7
|
|
|
6
8
|
function domReady() {
|
|
7
9
|
return new Promise((resolve) => onDomReady(resolve));
|
|
@@ -98,6 +100,8 @@ function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility
|
|
|
98
100
|
clay.readData = pageData.readData;
|
|
99
101
|
clay.writeData = pageData.writeData;
|
|
100
102
|
clay.ephemeral = ephemeral;
|
|
103
|
+
clay.effect = effect;
|
|
104
|
+
clay.style = style;
|
|
101
105
|
|
|
102
106
|
if (save) {
|
|
103
107
|
const saveFn = save.savePage || save.default;
|
|
@@ -133,6 +137,8 @@ function attachPluginMember(path, mod, loaded) {
|
|
|
133
137
|
loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
|
|
134
138
|
} else if (path === "plugins/live.js") {
|
|
135
139
|
clay.live = mod.live;
|
|
140
|
+
} else if (path === "plugins/page.js") {
|
|
141
|
+
clay.page = mod.page;
|
|
136
142
|
} else if (path === "plugins/cms.js") {
|
|
137
143
|
clay.cms = mod.cms || mod.default;
|
|
138
144
|
} 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
|
+
}
|