@panphora/clayjs 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -29,6 +29,7 @@
29
29
  "exports": {
30
30
  "./package.json": "./package.json",
31
31
  "./THIRD-PARTY-NOTICES.md": "./THIRD-PARTY-NOTICES.md",
32
+ "./guide.md": "./docs/guide.md",
32
33
  "./src/*": "./src/*",
33
34
  "./entries/*": "./entries/*",
34
35
  "./clay.standalone.js": "./dist/clay.standalone.js",
@@ -41,6 +42,7 @@
41
42
  "entries/",
42
43
  "dist/",
43
44
  "THIRD-PARTY-NOTICES.md",
45
+ "docs/guide.md",
44
46
  "src/"
45
47
  ],
46
48
  "publishConfig": {
@@ -60,6 +62,7 @@
60
62
  "check:foundation": "node scripts/propagate-foundation.mjs --check",
61
63
  "deploy": "node build.js && wrangler deploy && git rev-parse HEAD > .deploy",
62
64
  "build:parse5": "node scripts/build-parse5-vendor.mjs",
65
+ "build:prosemirror": "node scripts/build-prosemirror-vendor.mjs",
63
66
  "build:bevel": "node scripts/build-bevel-subset.mjs",
64
67
  "check:bevel": "node scripts/build-bevel-subset.mjs --check",
65
68
  "build:skins": "node scripts/build-vendor-skins.mjs",
@@ -79,6 +82,16 @@
79
82
  "parse5": "^7.3.0",
80
83
  "playwright": "1.62.1",
81
84
  "postcss": "^8.5.28",
85
+ "prosemirror-commands": "1.7.2",
86
+ "prosemirror-dropcursor": "1.8.4",
87
+ "prosemirror-gapcursor": "1.4.1",
88
+ "prosemirror-history": "1.5.1",
89
+ "prosemirror-inputrules": "1.5.1",
90
+ "prosemirror-keymap": "1.2.3",
91
+ "prosemirror-model": "1.25.12",
92
+ "prosemirror-state": "1.4.4",
93
+ "prosemirror-transform": "1.12.2",
94
+ "prosemirror-view": "1.42.6",
82
95
  "turndown": "^7.2.4",
83
96
  "turndown-plugin-gfm": "^1.0.2"
84
97
  },
@@ -13,6 +13,6 @@
13
13
  "@panphora/clayjs/entries/sap.js",
14
14
  "@panphora/clayjs/clay.standalone.js"
15
15
  ],
16
- "members": ["THIRD-PARTY-NOTICES.md"],
16
+ "members": ["THIRD-PARTY-NOTICES.md", "docs/guide.md"],
17
17
  "families": ["./entries/*", "./dist/*", "./src/*"]
18
18
  }
@@ -11,9 +11,9 @@
11
11
  const sources = new Map();
12
12
  let nextKey = 0;
13
13
 
14
- export function registerUnsavedState({ id = '', isPending }) {
14
+ export function registerUnsavedState({ id = '', isPending, flush = null }) {
15
15
  const key = ++nextKey;
16
- sources.set(key, { id, isPending });
16
+ sources.set(key, { id, isPending, flush });
17
17
  let disposed = false;
18
18
  return {
19
19
  changed() {
@@ -49,6 +49,15 @@ export function hasUnsavedState() {
49
49
  return false;
50
50
  }
51
51
 
52
+ // Ask every pending source that can finish on its own (a checkpoint, a queued
53
+ // upload) to do so, and wait. Sources without a flush (an open composition)
54
+ // stay pending; they are listed in the answer, never waited on.
55
+ export async function flushUnsaved() {
56
+ const flushes = [...sources.values()].filter((s) => typeof s.flush === 'function' && isPending(s));
57
+ const results = await Promise.all(flushes.map((s) => Promise.resolve().then(s.flush).catch(() => ({ ok: false }))));
58
+ return { ok: results.every((r) => r?.ok !== false), pending: pendingIds() };
59
+ }
60
+
52
61
  if (typeof window !== 'undefined') {
53
62
  window.clay = window.clay || {};
54
63
  window.clay.registerUnsavedState = registerUnsavedState;
@@ -95,6 +95,13 @@ export function createCheckpoints({ needsSave, blocked, save, saveNow, now = ()
95
95
  const at = Math.max(now(), retryAt);
96
96
  if (deadline === null || at < deadline) arm(at);
97
97
  },
98
+ // Save no later than ms from now (the page editor's pause save). Never
99
+ // later than a deadline already set, never before a failed save's retry time.
100
+ within(ms) {
101
+ if (running) return;
102
+ const at = Math.max(now() + ms, retryAt);
103
+ if (deadline === null || at < deadline) arm(at);
104
+ },
98
105
  // True while the hide/close save is being made.
99
106
  get flushing() {
100
107
  return flushing;
@@ -91,6 +91,8 @@ export function createFastLane({
91
91
  // newest op id ever put in a frame.
92
92
  const lanes = new Map();
93
93
 
94
+ const tell = (name, event) => entries.get(name)?.store.deliver?.({ code: null, n: null, seq: null, through: null, retryAfter: null, ...event });
95
+
94
96
  function laneOf(name) {
95
97
  let lane = lanes.get(name);
96
98
  if (!lane) lanes.set(name, (lane = { n: 0, unsent: [], awaiting: [], framed: 0 }));
@@ -115,7 +117,7 @@ export function createFastLane({
115
117
  const store = entries.get(name)?.store;
116
118
  let through = 0;
117
119
  for (const frame of lane.awaiting) if (!unapplied(frame)) through = frame.through;
118
- if (store && through) store.rebase(store.authoritative, { source: "state", through });
120
+ if (store && through) store.rebase(store.authoritative, { source: "state", through, cause: "leave" });
119
121
  onLeave(name, { epoch, seq });
120
122
  lane.unsent = [];
121
123
  lane.awaiting = [];
@@ -156,7 +158,8 @@ export function createFastLane({
156
158
  const [entry] = lane.unsent.splice(0, 1);
157
159
  console.warn(`clay.live("${name}"): one update was larger than ${FRAME_BYTES} bytes, so it was undone`);
158
160
  const store = entries.get(name)?.store;
159
- store?.rebase(store.authoritative, { source: "state", through: entry.id });
161
+ store?.rebase(store.authoritative, { source: "state", through: entry.id, cause: "undone" });
162
+ tell(name, { phase: "undone", code: "op-too-large", through: entry.id });
160
163
  continue;
161
164
  }
162
165
  const take = lane.unsent.splice(0, count);
@@ -178,6 +181,7 @@ export function createFastLane({
178
181
  const sentEpoch = epoch;
179
182
  frame.sentOn = connection;
180
183
  frame.sent = true;
184
+ tell(name, { phase: "sent", n: frame.n, through: frame.through });
181
185
  // Refusal is evidence about earlier attempts only: this one may apply.
182
186
  frame.refused = false;
183
187
  inFlight = true;
@@ -212,7 +216,7 @@ export function createFastLane({
212
216
  }
213
217
  lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
214
218
  const store = entries.get(name)?.store;
215
- store?.rebase(store.authoritative, { source: "state", through: frame.through });
219
+ store?.rebase(store.authoritative, { source: "state", through: frame.through, cause: "decided" });
216
220
  }
217
221
 
218
222
  // Every frame the room ordered at or before the applied seq is in the
@@ -225,7 +229,7 @@ export function createFastLane({
225
229
  }
226
230
  if (through === undefined) continue;
227
231
  const store = entries.get(name)?.store;
228
- store?.rebase(store.authoritative, { source: "state", through });
232
+ store?.rebase(store.authoritative, { source: "state", through, cause: "settled" });
229
233
  }
230
234
  }
231
235
 
@@ -246,12 +250,14 @@ export function createFastLane({
246
250
  return;
247
251
  }
248
252
  frame.seq = Number.isInteger(body?.seq) ? body.seq : null;
253
+ tell(name, { phase: "landed", n: frame.n, seq: frame.seq, through: frame.through });
249
254
  // Its event already went by, or came inside a state this tab took: the ops are in.
250
255
  settleAwaiting();
251
256
  return;
252
257
  }
253
258
  if (transient(status)) {
254
259
  frame.sent = false;
260
+ tell(name, { phase: "retry", code: body?.code ?? (status === 0 ? "offline" : status === 429 ? "rate-limited" : "server"), n: frame.n, through: frame.through, retryAfter: retryAfter ?? null });
255
261
  if (unhandled(status)) frame.refused = true;
256
262
  else {
257
263
  frame.uncertain = true;
@@ -268,6 +274,7 @@ export function createFastLane({
268
274
  // The room holds a receipt for every connected sender and has no room for
269
275
  // this one: this store goes at save speed until the room takes it back.
270
276
  if (status === 503 && body?.code === "busy") {
277
+ tell(name, { phase: "left", code: "busy", n: frame.n, through: frame.through });
271
278
  console.warn(`clay.live("${name}"): the room is full, so it stays at save speed for now`);
272
279
  roomNames.delete(name);
273
280
  leaveRoom(name, lane);
@@ -285,12 +292,14 @@ export function createFastLane({
285
292
  return;
286
293
  }
287
294
  if (REVOKED.has(status)) {
295
+ tell(name, { phase: "refused", code: "revoked", n: frame.n, through: frame.through });
288
296
  if (!frame.uncertain) frame.refused = true;
289
297
  stop();
290
298
  return;
291
299
  }
292
300
  const reason = body?.error || body?.code || `status ${status}`;
293
301
  if (status === 404) {
302
+ tell(name, { phase: "left", code: "no-store", n: frame.n, through: frame.through });
294
303
  console.warn(`clay.live("${name}"): the room has no such store, so it stays at save speed (${reason})`);
295
304
  frame.refused = true;
296
305
  roomNames.delete(name);
@@ -299,8 +308,9 @@ export function createFastLane({
299
308
  }
300
309
  lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
301
310
  const store = entries.get(name)?.store;
311
+ tell(name, { phase: "refused", code: body?.code ?? "refused", n: frame.n, through: frame.through });
302
312
  console.warn(`clay.live("${name}"): the host refused an update, so it was undone (${reason})`);
303
- store?.rebase(store.authoritative, { source: "state", through: frame.through });
313
+ store?.rebase(store.authoritative, { source: "state", through: frame.through, cause: "refused" });
304
314
  }
305
315
 
306
316
  function apply(frame) {
@@ -321,10 +331,11 @@ export function createFastLane({
321
331
  const i = lane.awaiting.findIndex((sent) => sent.n === frame.n);
322
332
  if (i >= 0) {
323
333
  through = lane.awaiting[i].through;
334
+ tell(frame.name, { phase: "echoed", n: frame.n, seq: frame.seq, through });
324
335
  lane.awaiting.splice(0, i + 1);
325
336
  }
326
337
  }
327
- store.rebase(next, { source: own ? "state" : "remote", through });
338
+ store.rebase(next, { source: own ? "state" : "remote", through, frame: { seq: frame.seq, from: frame.from, n: frame.n, ops: frame.ops } });
328
339
  entries.get(frame.name).last = frame.seq;
329
340
  onApplied();
330
341
  if (frame.checkpoint === true) onCheckpoint();
@@ -411,7 +422,7 @@ export function createFastLane({
411
422
  frame.seq = null;
412
423
  }
413
424
  lane.awaiting = resend;
414
- entry.store.rebase(room.value, { source: "state", through: through || undefined });
425
+ entry.store.rebase(room.value, { source: "state", through: through || undefined, room: { seq, fresh: true, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
415
426
  lane.unsent = unframed(name, lane);
416
427
  } else {
417
428
  const seen = room.seen ? room.seen[clientId] : undefined;
@@ -439,7 +450,7 @@ export function createFastLane({
439
450
  }
440
451
  }
441
452
  lane.awaiting = next;
442
- entry.store.rebase(room.value, { source: "state", through });
453
+ entry.store.rebase(room.value, { source: "state", through, room: { seq, fresh: false, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
443
454
  // Back in the room after a spell at save speed: frame what was made meanwhile.
444
455
  if (rejoined) {
445
456
  lane.unsent = unframed(name, lane);
@@ -476,7 +487,7 @@ export function createFastLane({
476
487
  const asked = { connection, stateCount };
477
488
  let state = null;
478
489
  try {
479
- state = await transport.fetchState(clientId);
490
+ state = await transport.fetchState(clientId, epoch !== null && seq > 0 ? { epoch, seq } : null);
480
491
  } catch {}
481
492
  fetching = false;
482
493
  if (stopped) return;
@@ -544,7 +555,10 @@ export function createFastLane({
544
555
  stateCount++;
545
556
  for (const [name, landed] of Object.entries(data.landed)) {
546
557
  const entry = entries.get(name);
547
- if (entry && Number.isInteger(landed)) entry.landed = landed;
558
+ if (entry && Number.isInteger(landed)) {
559
+ entry.landed = landed;
560
+ tell(name, { phase: "checkpointed", seq: landed });
561
+ }
548
562
  }
549
563
  onLanded();
550
564
  }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { applyOps, applyOp, checkOp, cloneJson, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
22
22
  import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
23
+ import { movesSince } from "./moves.js";
23
24
 
24
25
  const VERSION = 4;
25
26
  export const REQUEST_MS = 2000;
@@ -118,14 +119,20 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
118
119
 
119
120
  const landedIn = (m) => (m.behind || m.landed === null ? -1 : m.landed);
120
121
 
121
- function roomState() {
122
+ function roomState(since = null) {
122
123
  const stores = {};
123
124
  const meta = {};
124
125
  for (const [name, m] of mirror.stores) {
125
126
  stores[name] = { value: cloneJson(m.value), ephemeral: m.ephemeral, seen: receiptsFor(name), last: m.last, landed: landedIn(m) };
126
127
  meta[name] = { log: m.log, last: m.last, landed: m.landed, floor: m.floor, behind: m.behind, file: m.file, inFile: m.inFile };
127
128
  }
128
- return { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
129
+ const state = { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
130
+ if (since && since.epoch === mirror.epoch) {
131
+ const moves = {};
132
+ for (const [name, m] of mirror.stores) moves[name] = movesSince(m, since);
133
+ state.moves = moves;
134
+ }
135
+ return state;
129
136
  }
130
137
 
131
138
  function alreadyMerged(m, frame) {
@@ -507,7 +514,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
507
514
  } else if (room && m.type === "post") {
508
515
  if (m.epoch === room.epoch) post({ type: "answer", to: m.from, rid: m.rid, result: roomApply(m.from, m.frame) });
509
516
  } else if (room && m.type === "state-request") {
510
- post({ type: "answer", to: m.from, rid: m.rid, result: roomState() });
517
+ post({ type: "answer", to: m.from, rid: m.rid, result: roomState(m.since) });
511
518
  }
512
519
  }
513
520
 
@@ -554,11 +561,11 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
554
561
  // The sequencer's state, installed into the mirror first. An answer to a
555
562
  // request made before the mirror changed (another state, or this tab
556
563
  // starting a room) is dropped: the lane asks again or already has it.
557
- async fetchState() {
558
- if (room) return roomState();
564
+ async fetchState(_clientId, since = null) {
565
+ if (room) return roomState(since);
559
566
  const asked = gen;
560
- const state = await request({ type: "state-request" });
561
- if (room) return roomState();
567
+ const state = await request({ type: "state-request", since });
568
+ if (room) return roomState(since);
562
569
  if (asked !== gen || !install(state)) return null;
563
570
  return state;
564
571
  },
@@ -0,0 +1,11 @@
1
+ // The moves a store's log holds after `since` ({ epoch, seq }), for a tab that
2
+ // already applied everything through since.seq. [] when nothing is newer; null
3
+ // when the log no longer reaches back that far (its floor is past since.seq),
4
+ // so the tab cannot replay and takes the value as it is. A log may start above
5
+ // since.seq + 1 with nothing missing: the seqs between went to other stores.
6
+ export function movesSince(store, since) {
7
+ if (!since || !Number.isInteger(since.seq)) return null;
8
+ if (since.seq >= (store.last ?? 0)) return [];
9
+ if (since.seq < (store.floor ?? 0)) return null;
10
+ return store.log.filter((entry) => entry.seq > since.seq).map((entry) => ({ seq: entry.seq, ops: entry.ops }));
11
+ }
@@ -39,9 +39,10 @@ export async function relayData(clientId, frame) {
39
39
  * The room's { epoch, seq, writer, stores }; { status } when the host refused;
40
40
  * null when the request never got an answer. clientId marks this tab as taking part.
41
41
  */
42
- export async function fetchLiveState(clientId) {
42
+ export async function fetchLiveState(clientId, since = null) {
43
43
  const url = dataUrl();
44
44
  if (clientId) url.searchParams.set("client-id", clientId);
45
+ if (since && typeof since.epoch === "string" && Number.isInteger(since.seq)) url.searchParams.set("since", `${since.epoch}:${since.seq}`);
45
46
  try {
46
47
  const response = await fetch(url.href, {
47
48
  credentials: "same-origin",
package/src/live/store.js CHANGED
@@ -47,6 +47,8 @@ export function createStore({ name, value, ephemeral = false }) {
47
47
  let mode = "file";
48
48
  const listeners = new Set();
49
49
  const sinks = new Set();
50
+ const rebases = new Set();
51
+ const deliveries = new Set();
50
52
  const views = new WeakMap();
51
53
 
52
54
  function emit(source, ops) {
@@ -275,17 +277,46 @@ export function createStore({ name, value, ephemeral = false }) {
275
277
  // `confirmed` pending ops, or every op up to the mark `through`. The rest
276
278
  // replay over it; a pending op that no longer applies is dropped too.
277
279
  // Listeners hear about it only when the value page code sees changed.
278
- rebase(next, { source = "remote", confirmed = 0, through } = {}) {
280
+ rebase(next, { source = "remote", confirmed = 0, through, frame = null, room = null, cause = null } = {}) {
279
281
  if (!isContainer(next) || kind(next) !== kind(state)) {
280
282
  console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
281
283
  return false;
282
284
  }
283
285
  const before = serializeLive(state);
286
+ const prev = authoritative;
284
287
  authoritative = cloneJson(next);
285
288
  pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
286
289
  rebuild();
290
+ for (const fn of [...rebases]) {
291
+ try {
292
+ fn({ source, frame, room, cause, prev, next: authoritative });
293
+ } catch (err) {
294
+ console.error(`clay.live("${name}"): a rebase listener threw`, err);
295
+ }
296
+ }
287
297
  if (serializeLive(state) !== before) emit(source, null);
288
298
  return true;
289
299
  },
300
+ // Every rebase, whether or not the value page code sees changed, with what
301
+ // caused it: the frame the room applied, the state read, or the lane's own
302
+ // settling. For the page editor; never on the handle.
303
+ onRebase(fn) {
304
+ rebases.add(fn);
305
+ return () => rebases.delete(fn);
306
+ },
307
+ // What became of this tab's frames: { phase, code, n, seq, through, retryAfter }.
308
+ onDelivery(fn) {
309
+ deliveries.add(fn);
310
+ return () => deliveries.delete(fn);
311
+ },
312
+ deliver(event) {
313
+ for (const fn of [...deliveries]) {
314
+ try {
315
+ fn(event);
316
+ } catch (err) {
317
+ console.error(`clay.live("${name}"): a delivery listener threw`, err);
318
+ }
319
+ }
320
+ },
290
321
  };
291
322
  }
@@ -42,6 +42,9 @@ export const PLUGIN_PATHS = {
42
42
  // Who is editing and who wrote what. Not editOnly: a reader still sees names.
43
43
  people: { path: "plugins/people.js", editOnly: false, default: false },
44
44
  wire: { path: "plugins/wire.js", editOnly: false, default: false },
45
+ // A visitor's contact form to the document's owner. Not editOnly: visitors are
46
+ // who send messages.
47
+ message: { path: "plugins/message.js", editOnly: false, default: false },
45
48
  // Edit mode only: the AI comment box is an editing gesture. Opt in with
46
49
  // `plugins=ai-edit`; it stays dormant on any host that does not list a ready
47
50
  // `ai-edit` helper.
@@ -100,6 +103,7 @@ export const MODULES = {
100
103
  "plugins/upload.js": () => import("./plugins/upload.js"),
101
104
  "plugins/people.js": () => import("./plugins/people.js"),
102
105
  "plugins/wire.js": () => import("./plugins/wire.js"),
106
+ "plugins/message.js": () => import("./plugins/message.js"),
103
107
  "plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
104
108
  "plugins/demo.js": () => import("./plugins/demo.js"),
105
109
  "plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
@@ -108,7 +112,7 @@ export const MODULES = {
108
112
 
109
113
  // `source` is last on purpose: its install captures a save clone, so it wants every
110
114
  // plugin that registers a document transform to have registered it first.
111
- const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
115
+ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "message", "wire", "ai-edit", "demo", "source"];
112
116
 
113
117
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
114
118
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
package/src/loader.js CHANGED
@@ -141,6 +141,8 @@ function attachPluginMember(path, mod, loaded) {
141
141
  clay.people = mod.people;
142
142
  clay.author = mod.author;
143
143
  Object.defineProperty(clay, "me", { get: mod.currentMe, configurable: true, enumerable: true });
144
+ } else if (path === "plugins/message.js") {
145
+ clay.message = mod.message;
144
146
  } else if (path === "plugins/wire.js") {
145
147
  clay.wire = mod.wire || mod.default;
146
148
  } else if (path === "plugins/ai-edit.js") {