@panphora/clayjs 1.10.0 → 1.11.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.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * [add-from] Custom Attribute
3
+ *
4
+ * Adds a new item from a <template> on click. Edit mode only: this module is in
5
+ * the edit-only wave, so a visitor's page never loads it.
6
+ *
7
+ * Usage:
8
+ * <button add-from="#task-tpl" add-to="#tasks">Add task</button>
9
+ *
10
+ * The template's first element is cloned, given a fresh data-id unless it already
11
+ * has one (templates carry none), and appended to the add-to element. Its first
12
+ * editable field is focused with its text selected, so typing replaces the
13
+ * placeholder. Ids are built without crypto.randomUUID, which is missing outside
14
+ * a secure context.
15
+ */
16
+
17
+ const FIELD = '[contenteditable]:not([contenteditable="false"]), input, textarea';
18
+
19
+ export function newId() {
20
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
21
+ }
22
+
23
+ function find(selector) {
24
+ if (!selector) return null;
25
+ try {
26
+ return document.querySelector(selector);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function focusField(item) {
33
+ const field = item.matches(FIELD) ? item : item.querySelector(FIELD);
34
+ if (!field) return;
35
+ field.focus();
36
+ if (typeof field.select === "function") return field.select();
37
+ const range = document.createRange();
38
+ range.selectNodeContents(field);
39
+ const selection = window.getSelection();
40
+ selection.removeAllRanges();
41
+ selection.addRange(range);
42
+ }
43
+
44
+ export function addFrom(trigger) {
45
+ const template = find(trigger.getAttribute("add-from"));
46
+ const list = find(trigger.getAttribute("add-to"));
47
+ const blueprint = template?.content?.firstElementChild;
48
+ if (!blueprint || !list) {
49
+ console.warn("[add-from] add-from must name a <template> and add-to the element to add into", trigger);
50
+ return null;
51
+ }
52
+ const item = blueprint.cloneNode(true);
53
+ if (!item.getAttribute("data-id")) item.setAttribute("data-id", newId());
54
+ list.append(item);
55
+ focusField(item);
56
+ return item;
57
+ }
58
+
59
+ function onClick(event) {
60
+ const trigger = event.target.closest?.("[add-from]");
61
+ if (!trigger) return;
62
+ event.preventDefault();
63
+ addFrom(trigger);
64
+ }
65
+
66
+ function init() {
67
+ document.addEventListener("click", onClick);
68
+ }
69
+
70
+ init();
71
+
72
+ export default init;
@@ -37,6 +37,7 @@ const MINIMIZE_SVG = '<svg viewBox="0 0 12 12" width="12" height="12" aria-hidde
37
37
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
38
38
 
39
39
  const BLOCKED_ROW = "This spot changed again. Review the latest edit or download your copy.";
40
+ const DATA_BLOCKED_ROW = "A data value can't be put back from here. Download your copy to keep yours.";
40
41
 
41
42
  let view = "hidden";
42
43
  let listOpen = false;
@@ -173,10 +174,6 @@ function render() {
173
174
  root.append(drawPanel(m));
174
175
  }
175
176
  set(root, "display", toastText || view !== "hidden" ? "flex" : "none");
176
- if (view !== "hidden") {
177
- const section = document.querySelector("[data-clay-section-notice]");
178
- if (section) set(section, "display", "none");
179
- }
180
177
  if (wellEl) wellEl.scrollTop = scroll;
181
178
 
182
179
  const want = pendingFocus || focusKey;
@@ -514,7 +511,7 @@ async function onRevert() {
514
511
  document.removeEventListener("focus", track, true);
515
512
  }
516
513
  busy = null;
517
- for (const id of result.blockedIds) blockedRows.set(id, BLOCKED_ROW);
514
+ for (const id of result.blockedIds) blockedRows.set(id, ledger()?.get(id)?.kind === "json" ? DATA_BLOCKED_ROW : BLOCKED_ROW);
518
515
  view = mode() ? "bar" : "hidden";
519
516
  pendingFocus = stayed ? "review" : null;
520
517
  const saving = result.saveResult && (result.saveResult.ok || result.saveResult.msg === "Save already in progress");
@@ -262,6 +262,41 @@ function recoveryRow(record, rv) {
262
262
  };
263
263
  }
264
264
 
265
+ // A JSON conflict path ('players[id="alice"].x', 'grid[0][1]', '["a b"]') as the words
266
+ // a person reads: players → alice → x. A keyed segment shows its key's value.
267
+ export function jsonPathWords(path) {
268
+ const words = [];
269
+ let i = 0;
270
+ while (i < path.length) {
271
+ if (path[i] === ".") { i++; continue; }
272
+ if (path[i] === "[") {
273
+ let j = i + 1;
274
+ let inString = false;
275
+ for (; j < path.length; j++) {
276
+ const ch = path[j];
277
+ if (inString) {
278
+ if (ch === "\\") j++;
279
+ else if (ch === '"') inString = false;
280
+ } else if (ch === '"') inString = true;
281
+ else if (ch === "]") break;
282
+ }
283
+ let inner = path.slice(i + 1, j);
284
+ const keyed = /^[A-Za-z_$][\w$]*=/.exec(inner);
285
+ if (keyed) inner = inner.slice(keyed[0].length);
286
+ try { words.push(String(JSON.parse(inner))); } catch { words.push(inner); }
287
+ i = j + 1;
288
+ continue;
289
+ }
290
+ let j = i;
291
+ while (j < path.length && path[j] !== "." && path[j] !== "[") j++;
292
+ words.push(path.slice(i, j));
293
+ i = j;
294
+ }
295
+ return words.join(" → ");
296
+ }
297
+
298
+ const jsonValue = (v) => (v === undefined ? "(deleted)" : JSON.stringify(v));
299
+
265
300
  export function rowOf(record) {
266
301
  const id = record.id;
267
302
  if (record.recovery?.version === 1) return recoveryRow(record, record.recovery);
@@ -283,6 +318,13 @@ export function rowOf(record) {
283
318
  yours: both ? side(plain(record.local)) : null, now: both ? side(plain(record.remote)) : null, sentence,
284
319
  };
285
320
  }
321
+ if (record.kind === "json") {
322
+ const where = jsonPathWords(record.path || "");
323
+ return {
324
+ id, kind: "json", name: where ? `${record.name} → ${where}` : record.name, target: null,
325
+ yours: side(jsonValue(record.local)), now: side(jsonValue(record.resolved)), sentence: null,
326
+ };
327
+ }
286
328
  const host = hostOf(record.node);
287
329
  const el = elementOf(record.node);
288
330
  const name = el && HIDDEN.has(el.localName) ? kindName(el)
@@ -43,6 +43,20 @@ let nextSaveKeepalive = false;
43
43
  // CustomEvent would put it on a bus any script on the page can listen to.
44
44
  const saveAcceptedHooks = [];
45
45
 
46
+ // Headers another module asks every save to carry, by name.
47
+ const extraSaveHeaders = new Map();
48
+
49
+ /**
50
+ * Add a header to every save from now on, or remove it with a null value.
51
+ *
52
+ * @param {string} name
53
+ * @param {string|null} value
54
+ */
55
+ export function setSaveHeader(name, value) {
56
+ if (value == null) extraSaveHeaders.delete(name);
57
+ else extraSaveHeaders.set(name, String(value));
58
+ }
59
+
46
60
  /**
47
61
  * Send the next save with `keepalive`, so a request whose page is going away (a tab
48
62
  * closing, a navigation) is still finished by the transport.
@@ -238,6 +252,11 @@ function errorResult(err) {
238
252
  // Live sync compares it with the frames it merges to know when the refusal
239
253
  // is answered.
240
254
  conflictEtag: conflicted ? (err.etag ?? null) : null,
255
+ // The host's answer, null when none came (a lost connection or a timeout).
256
+ status: Number.isInteger(err.status) ? err.status : null,
257
+ // An attempt of this save timed out, so it may have landed whatever the
258
+ // final answer says.
259
+ hadTimeout: timedOut || err.hadTimeout === true,
241
260
  etag: null
242
261
  };
243
262
  }
@@ -312,6 +331,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
312
331
  body: content
313
332
  };
314
333
 
334
+ for (const [name, value] of extraSaveHeaders) options.headers[name] = value;
335
+
315
336
  if (etag) options.headers['If-Match'] = etag;
316
337
 
317
338
  if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
@@ -559,6 +580,7 @@ async function sendSave(content) {
559
580
  try {
560
581
  return await sendOnce(content, saveId, etag);
561
582
  } catch (err) {
583
+ if (reSent) err.hadTimeout = true;
562
584
  if (err.name === 'AbortError') {
563
585
  if (reSent) throw err;
564
586
  const { landed, resend, etag: original } = await recoverUnknownSave();
package/src/core/save.js CHANGED
@@ -221,6 +221,13 @@ export function resumeAutosave() {
221
221
  savePageThrottled();
222
222
  }
223
223
 
224
+ // Whether an agent's request currently holds autosave off. A caller that forces a
225
+ // save on its own schedule (live data checkpoints) asks this first, because a
226
+ // forced save would write the browser's bytes over the agent's.
227
+ export function autosaveIsSuspended() {
228
+ return autosaveSuspended > 0;
229
+ }
230
+
224
231
  // ============================================
225
232
  // THE ALL-SAVE HOLD
226
233
  // ============================================
@@ -372,15 +379,17 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
372
379
  } else if (result.msgType === 'conflict') {
373
380
  holdForConflict(result.conflictEtag);
374
381
  setSaveState('conflict', result.msg, result.msgType, {
382
+ hadTimeout: result.hadTimeout === true,
375
383
  changedBy: result.changedBy ?? null,
376
384
  afterTimeout: result.afterTimeout === true,
377
385
  etag: result.conflictEtag ?? null,
378
386
  });
379
387
  } else if (result.msgType !== 'skipped') {
388
+ const extra = { status: result.status ?? null, hadTimeout: result.hadTimeout === true };
380
389
  if (!navigator.onLine) {
381
- setSaveState('offline', result.msg);
390
+ setSaveState('offline', result.msg, '', extra);
382
391
  } else {
383
- setSaveState('error', result.msg);
392
+ setSaveState('error', result.msg, '', extra);
384
393
  }
385
394
  }
386
395
  }
@@ -21,6 +21,7 @@ export const POLICY_TOKENS = Object.freeze([
21
21
  'no-data',
22
22
  'freeze',
23
23
  'editor-ui',
24
+ 'live',
24
25
  ])
25
26
 
26
27
  export function hasPolicyToken(element, token) {
@@ -49,7 +49,7 @@ import { capabilitySelector, expandsTo, hasCapability, hasPolicyToken, POLICY_TO
49
49
  export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
50
50
 
51
51
  // The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
52
- export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui'];
52
+ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui', 'live'];
53
53
 
54
54
  // Every canonical token spellable inside the space-separated `clay` attribute.
55
55
  // Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
@@ -83,7 +83,7 @@ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
83
83
  // mutations-ignore footgun (their content stays in the saved file, but is no
84
84
  // longer counted as a change).
85
85
  export const STRIP_FROM_COMPARISON =
86
- '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
86
+ '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
87
87
 
88
88
  // Every spelling of no-trigger-autosave: the ONE difference between the autosave
89
89
  // domain and the dirty domain. A document containing none of these has identical
@@ -94,6 +94,10 @@ export const STRIP_FROM_COMPARISON =
94
94
  export const NO_TRIGGER_AUTOSAVE_SELECTOR =
95
95
  '[clay~="no-trigger-autosave"], [no-trigger-autosave]';
96
96
 
97
+ // Live JSON stores (clay="live" on a merge= JSON script). Saved, stripped from both
98
+ // comparison domains, and ignored by the live-sync merge: the live plugin owns the value.
99
+ export const LIVE_SELECTOR = '[clay~="live"]';
100
+
97
101
  // Disposable regions: saved, but their content is explicitly not work. Stripped
98
102
  // from BOTH comparison domains, skipped by the merge on both sides, and never
99
103
  // counted by the close warning. This is the declared signal that lets the merge
@@ -108,7 +112,7 @@ export const NO_DIRTY_SELECTOR =
108
112
  // dirty-tracked either, and counting it would re-freeze the live-sync baseline
109
113
  // the way the mounted-tool bug did (see dirty-gate.js).
110
114
  export const STRIP_FROM_DIRTY_CHECK =
111
- '[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
115
+ '[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
112
116
 
113
117
  // Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
114
118
  // dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
@@ -163,6 +167,9 @@ export function resolveRegionPolicy(node) {
163
167
  if (expandsTo(element, 'no-watch')) watched = false;
164
168
  if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
165
169
  if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
170
+ // A live JSON store: saved, but its value lives in memory and the live plugin
171
+ // writes it into each save clone, so the tag itself is never work to track.
172
+ if (hasRegionToken(element, 'live')) { autosaveOff = true; dirtyOff = true; undoable = false; }
166
173
  if (expandsTo(element, 'no-undo')) undoable = false;
167
174
  if (expandsTo(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
168
175
  if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
@@ -0,0 +1,106 @@
1
+ // Checkpoints for clay.live stores. A live change never marks the page unsaved
2
+ // (live tags are outside the comparison domains), so nothing else would ever save
3
+ // it. This does: at a fixed deadline CHECKPOINT_MS after the first unsaved change,
4
+ // so a steady stream of moves still saves on time, and once more, right away,
5
+ // when the tab is hidden or closed.
6
+
7
+ export const CHECKPOINT_MS = 8000;
8
+ export const RETRY_MS = 1000;
9
+
10
+ /**
11
+ * @param {object} deps
12
+ * @param {() => boolean} deps.needsSave - is there a live change no accepted save holds
13
+ * @param {() => boolean} deps.blocked - must a save wait (settling, conflict, agent editing)
14
+ * @param {() => Promise<object>} deps.save - the checkpoint save; resolves { ok, msgType }
15
+ * @param {() => unknown} deps.saveNow - the hide/close save, sent with keepalive
16
+ */
17
+ export function createCheckpoints({ needsSave, blocked, save, saveNow, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout }) {
18
+ let deadline = null;
19
+ let timer = null;
20
+ let running = false;
21
+ let flushing = false;
22
+ // After a save that did not land, no save before this time (soon included).
23
+ let retryAt = 0;
24
+
25
+ function after(result) {
26
+ const skipped = result && !result.ok && result.msgType === "skipped";
27
+ retryAt = result?.ok || skipped ? 0 : now() + CHECKPOINT_MS;
28
+ return skipped ? RETRY_MS : CHECKPOINT_MS;
29
+ }
30
+
31
+ function arm(at) {
32
+ deadline = at;
33
+ if (timer !== null) clearTimer(timer);
34
+ timer = setTimer(run, Math.max(0, at - now()));
35
+ }
36
+
37
+ function disarm() {
38
+ if (timer !== null) clearTimer(timer);
39
+ timer = null;
40
+ deadline = null;
41
+ }
42
+
43
+ async function run() {
44
+ timer = null;
45
+ deadline = null;
46
+ if (!needsSave()) return;
47
+ if (blocked()) {
48
+ arm(now() + RETRY_MS);
49
+ return;
50
+ }
51
+ running = true;
52
+ let result = null;
53
+ try {
54
+ result = await save();
55
+ } catch {
56
+ result = null;
57
+ } finally {
58
+ running = false;
59
+ }
60
+ const wait = after(result);
61
+ if (!needsSave()) return;
62
+ // A save skipped because another was on the wire is retried soon; anything
63
+ // else waits a full interval, so a failing host is not asked every second.
64
+ arm(now() + wait);
65
+ }
66
+
67
+ return {
68
+ // A live change happened. The first one after a save starts the clock; later
69
+ // ones do not move it.
70
+ changed() {
71
+ if (deadline === null && !running) arm(now() + CHECKPOINT_MS);
72
+ },
73
+ // The tab is going away or out of sight: save now if anything is unsaved.
74
+ // A save that did not land arms the clock again, as run() does.
75
+ flush() {
76
+ if (running || !needsSave() || blocked()) return;
77
+ disarm();
78
+ running = true;
79
+ flushing = true;
80
+ Promise.resolve()
81
+ .then(saveNow)
82
+ .catch(() => null)
83
+ .then((result) => {
84
+ running = false;
85
+ flushing = false;
86
+ const wait = after(result);
87
+ if (!needsSave()) return;
88
+ arm(now() + wait);
89
+ });
90
+ },
91
+ // Save as soon as possible: the room asks for it (its log is half full, or
92
+ // the file holds a version the room cannot rebuild from).
93
+ soon() {
94
+ if (running) return;
95
+ const at = Math.max(now(), retryAt);
96
+ if (deadline === null || at < deadline) arm(at);
97
+ },
98
+ // True while the hide/close save is being made.
99
+ get flushing() {
100
+ return flushing;
101
+ },
102
+ get deadline() {
103
+ return deadline;
104
+ },
105
+ };
106
+ }