@panphora/clayjs 1.14.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/save.js CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  saveFateIsUnknown,
22
22
  withKeepalive
23
23
  } from "./save-core.js";
24
- import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck } from "./snapshot.js";
24
+ import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone } from "./snapshot.js";
25
+ import { paintSignature } from "../lib/derived.js";
25
26
  import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
26
27
  import { seedEtag, lastSeenEtag } from "./etag.js";
27
28
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
@@ -37,6 +38,7 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
37
38
  // Both read across it at call time, never while a module is evaluating, so neither
38
39
  // side sees a half-built module.
39
40
  import { bytesUnsaved } from "./unsaved-warning.js";
41
+ import { bootPhase } from "./boot-phase.js";
40
42
 
41
43
  // Keep this library's own root state out of the saved bytes.
42
44
  //
@@ -378,7 +380,7 @@ function skipped_(msg) {
378
380
  * where the write may or may not have landed, so it is not treated as success
379
381
  * either.
380
382
  */
381
- function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null) {
383
+ function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null, quietConflict = false) {
382
384
  if (result.ok) {
383
385
  // Both baselines advance from the SAME pre-request capture, never from the
384
386
  // live DOM, so an edit made while the request was on the wire stays unsaved.
@@ -394,6 +396,16 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
394
396
  logBaseline(label, `${lastSavedContents.length} chars`);
395
397
  releaseConflictHold();
396
398
  } else if (result.msgType === 'conflict') {
399
+ // A refused heal leaves the tab as it was: still clean, no save event, no
400
+ // hold. The save that beat it reaches this tab through sync.
401
+ if (quietConflict) {
402
+ if (savingTimeout) {
403
+ clearTimeout(savingTimeout);
404
+ savingTimeout = null;
405
+ }
406
+ document.documentElement.setAttribute('savestatus', 'saved');
407
+ return;
408
+ }
397
409
  holdForConflict(result.conflictEtag);
398
410
  setSaveState('conflict', result.msg, result.msgType, {
399
411
  hadTimeout: result.hadTimeout === true,
@@ -585,7 +597,7 @@ export function savePage(callback = () => {}) {
585
597
  * @param {Function} callback - Optional callback for custom handling
586
598
  * @returns {Promise<{msg: string, msgType: string}>}
587
599
  */
588
- export function savePageForce(callback = () => {}) {
600
+ export function savePageForce(callback = () => {}, { quietConflict = false } = {}) {
589
601
  return new Promise((resolve) => {
590
602
  if (!isEditMode && !window.clay?.testMode) {
591
603
  const skipped = skipped_('Not in edit mode');
@@ -629,7 +641,7 @@ export function savePageForce(callback = () => {}) {
629
641
  saveCapturedHtml(forSave, captureId, (result) => {
630
642
  ownSaveOnWire = false;
631
643
  inFlightComparisons.delete(comparison);
632
- applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId);
644
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId, quietConflict);
633
645
  if (typeof callback === 'function') {
634
646
  callback(result);
635
647
  }
@@ -797,6 +809,56 @@ let baselineActive = true;
797
809
  let baselineSettledAt = false;
798
810
  export function baselineSettled() { return baselineSettledAt; }
799
811
 
812
+ // A repaint that leaves the page's painted values different from the file's means
813
+ // the file is stale: an agent wrote a record without painting it. One clean,
814
+ // autosaving tab saves the painted page so the bytes on disk heal. The delay is
815
+ // jittered and the check reruns when it fires, so when several tabs repaint the
816
+ // same frame, the first save reaches the others and they skip.
817
+ const PAINT_HEAL_MIN_MS = 250;
818
+ const PAINT_HEAL_SPREAD_MS = 1250;
819
+ let paintHealTimer = null;
820
+
821
+ const HEAL_KEY = 'clay:paint-heal:' + location.pathname;
822
+
823
+ function hashSignature(s) {
824
+ let h = 5381;
825
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
826
+ return String(h >>> 0);
827
+ }
828
+
829
+ function healedFrom(signature) {
830
+ try { return localStorage.getItem(HEAL_KEY) === hashSignature(signature); } catch { return false; }
831
+ }
832
+
833
+ function noteHealedFrom(signature) {
834
+ try { localStorage.setItem(HEAL_KEY, hashSignature(signature)); } catch {}
835
+ }
836
+
837
+ /** The painted values of the page as it would be saved. */
838
+ export function currentPaintSignature() {
839
+ return paintSignature(captureSaveClone({ flushUndo: false }));
840
+ }
841
+
842
+ /**
843
+ * Save the page if its painted values differ from the file's. `fileSignature` is
844
+ * the file's paintSignature, or a function returning it (or null when unknown)
845
+ * when the timer fires.
846
+ */
847
+ export function schedulePaintHeal(fileSignature) {
848
+ if (!isEditMode || isDraftMode) return;
849
+ clearTimeout(paintHealTimer);
850
+ paintHealTimer = setTimeout(() => {
851
+ paintHealTimer = null;
852
+ const onDisk = typeof fileSignature === 'function' ? fileSignature() : fileSignature;
853
+ if (onDisk == null || !autosaveActive() || !baselineSettled() || isSaveConflicted() || autosaveIsSuspended()) return;
854
+ if (captureForDirtyCheck({ flushUndo: false }) !== lastSavedDirty) return;
855
+ if (onDisk === currentPaintSignature()) return;
856
+ if (healedFrom(onDisk)) return;
857
+ noteHealedFrom(onDisk);
858
+ savePageForce(() => {}, { quietConflict: true });
859
+ }, PAINT_HEAL_MIN_MS + Math.random() * PAINT_HEAL_SPREAD_MS);
860
+ }
861
+
800
862
  registerStyleAdoption(identity => {
801
863
  const strip = styleComparisonStripper(identity);
802
864
  lastSavedContents = strip(lastSavedContents);
@@ -825,6 +887,19 @@ registerStyleAdoption(identity => {
825
887
 
826
888
  const SETTLE_MS = 500; // Wait for no mutations for this long
827
889
  const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
890
+ const MAX_HOLD_MS = 10000; // A held settle releases itself after this
891
+
892
+ let settleNow = null;
893
+
894
+ /**
895
+ * The loader's mount phase calls this once the derive engine has taken its first
896
+ * pass. The settle runs at once on the painted page, unless a person already
897
+ * edited or a save already installed a baseline (captureBaseline's guard).
898
+ */
899
+ export function releaseBootSettle() {
900
+ bootPhase.holdSettle = false;
901
+ if (settleNow) settleNow();
902
+ }
828
903
 
829
904
  function initBaselineCapture() {
830
905
  if (!isEditMode) return;
@@ -892,19 +967,32 @@ function initBaselineCapture() {
892
967
  baselineSettledAt = true;
893
968
  document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
894
969
  };
970
+ settleNow = captureBaseline;
971
+
972
+ // While the loader holds the settle for a derive engine's first pass, neither
973
+ // the quiet timer nor the max timeout may settle; releaseBootSettle does.
974
+ const settle = () => {
975
+ if (!bootPhase.holdSettle) captureBaseline();
976
+ };
895
977
 
896
978
  // Start settle observer - fires when no mutations for SETTLE_MS.
897
979
  // require:'autosave' so churn in no-save / save-* / no-watch regions doesn't
898
980
  // keep resetting the settle timer or count toward the baseline.
899
981
  unsubscribeMutation = Mutation.onAnyChange(
900
982
  { debounce: SETTLE_MS, omitChangeDetails: true, require: 'autosave' },
901
- captureBaseline
983
+ settle
902
984
  );
903
985
 
904
986
  // Max timeout fallback
905
987
  setTimeout(() => {
906
- if (!settled) captureBaseline();
988
+ if (!settled) settle();
907
989
  }, MAX_SETTLE_MS);
990
+
991
+ // The loader releases a held settle after the derive engine mounts. If boot
992
+ // never gets there (a plugin's ready stalls, boot throws), release it anyway.
993
+ setTimeout(() => {
994
+ if (!settled && bootPhase.holdSettle) releaseBootSettle();
995
+ }, MAX_HOLD_MS);
908
996
  }
909
997
 
910
998
  // Run when DOM is ready
@@ -43,6 +43,7 @@ import { restoreAuthoredUrls } from '../lib/authored-url.js';
43
43
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
44
44
  import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
45
45
  import { stripEphemeral } from '../lib/ephemeral.js';
46
+ import { stripDerived } from '../lib/derived.js';
46
47
  import { createContentView } from '../lib/content-dom.js';
47
48
 
48
49
  // =============================================================================
@@ -313,9 +314,17 @@ function prepareCloneForSave(clone, { authored = true } = {}) {
313
314
  el.remove();
314
315
  }
315
316
 
317
+ stripDiagnosticBeacons(clone);
318
+
316
319
  return clone;
317
320
  }
318
321
 
322
+ // sapjs marks an element it cannot run with a sap-error beacon. It describes this
323
+ // tab's engine, never the document, so no save carries it.
324
+ function stripDiagnosticBeacons(clone) {
325
+ for (const el of clone.querySelectorAll("[sap-error]")) el.removeAttribute("sap-error");
326
+ }
327
+
319
328
  /**
320
329
  * The save-domain clone, for a caller that needs the tree rather than the bytes.
321
330
  *
@@ -344,6 +353,7 @@ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
344
353
  const EMPTY_INERT_ATTRS = ['style', 'class'];
345
354
 
346
355
  function serializeForCompare(clone) {
356
+ stripDerived(clone);
347
357
  for (const name of EMPTY_INERT_ATTRS) {
348
358
  if (clone.getAttribute(name) === '') clone.removeAttribute(name);
349
359
  for (const el of clone.querySelectorAll(`[${name}=""]`)) el.removeAttribute(name);
@@ -514,6 +524,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
514
524
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
515
525
  el.remove();
516
526
  }
527
+ stripDiagnosticBeacons(clone);
517
528
  const forSave = serializeSaveClone(clone);
518
529
 
519
530
  // Compare clone: strip every autosave-off region, then run hooks
@@ -596,6 +607,7 @@ export function captureForMerge() {
596
607
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
597
608
  el.remove();
598
609
  }
610
+ stripDiagnosticBeacons(clone);
599
611
 
600
612
  for (const el of compareClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
601
613
  el.remove();
@@ -0,0 +1,116 @@
1
+ import { ownsAttr, paintsOf, stripPaints, eachPainted } from "../vendor/sap-ownership.vendor.js";
2
+ import { bootPhase } from "../core/boot-phase.js";
3
+
4
+ // Values a derive engine (sapjs) paints from the page's records: attr:, class:,
5
+ // css:, text and show output inside a [sap] root. They are saved like any other
6
+ // content, but they are never an edit: comparisons, the dirty gate, undo and
7
+ // merges leave them out, and the engine repaints them after every merge, before
8
+ // anything records a baseline. Which parts of an element are painted is read
9
+ // from its own attributes, so the same answer holds for the live page, a
10
+ // snapshot clone and a parsed frame. No expression is evaluated here.
11
+
12
+ export const isDerivedAttr = (el, name) => ownsAttr(el, name);
13
+
14
+ export const ownsDerivedText = (el) => !!el && el.nodeType === 1 && !!paintsOf(el)?.text;
15
+
16
+ /** Remove every painted value from a detached tree, in place. Never the live page. */
17
+ export const stripDerived = (root) => stripPaints(root);
18
+
19
+ /** Every painted value under root, in document order, as one string. */
20
+ export function paintSignature(root) {
21
+ const parts = [];
22
+ eachPainted(root, (el, p) => {
23
+ parts.push(el.localName);
24
+ for (const n of p.attrs) if (n !== "sap-error") parts.push(n + "=" + (el.getAttribute(n) ?? "\u0000"));
25
+ for (const t of p.classes) parts.push("." + t + "=" + el.classList.contains(t));
26
+ for (const v of p.vars) parts.push(v + "=" + el.style.getPropertyValue(v));
27
+ if (p.text) parts.push("#" + el.textContent);
28
+ });
29
+ return parts.join("\u0001");
30
+ }
31
+
32
+ /** paintSignature of a document's bytes, such as the file on disk. */
33
+ export function paintSignatureOfHtml(html) {
34
+ return paintSignature(new DOMParser().parseFromString(html, "text/html"));
35
+ }
36
+
37
+ function withoutTokens(value, owned, split, join, nameOf) {
38
+ return split(value || "").map((t) => t.trim()).filter((t) => t && !owned.includes(nameOf(t))).sort().join(join);
39
+ }
40
+
41
+ // A class or style change that only touched painted tokens.
42
+ function onlyPaintedTokens(el, name, before, after) {
43
+ const p = paintsOf(el);
44
+ if (!p) return false;
45
+ if (name === "class") {
46
+ if (!p.classes.length) return false;
47
+ const rest = (v) => withoutTokens(v, p.classes, (s) => s.split(/\s+/), " ", (t) => t);
48
+ return rest(before) === rest(after);
49
+ }
50
+ if (!p.vars.length) return false;
51
+ const rest = (v) => withoutTokens(v, p.vars, (s) => s.split(";"), ";", (t) => t.split(":")[0].trim());
52
+ return rest(before) === rest(after);
53
+ }
54
+
55
+ /** True when a mutation-hub change only rewrote painted output. */
56
+ export function isDerivedChange(change) {
57
+ if (change.__derived !== undefined) return change.__derived;
58
+ const el = change.element;
59
+ let derived = false;
60
+ if (el && el.nodeType === 1) {
61
+ if (change.type === "attribute") {
62
+ const name = change.attribute;
63
+ derived = isDerivedAttr(el, name) ||
64
+ ((name === "class" || name === "style") && onlyPaintedTokens(el, name, change.oldValue, change.newValue));
65
+ } else if (change.type === "characterData") {
66
+ derived = ownsDerivedText(el);
67
+ }
68
+ }
69
+ change.__derived = derived;
70
+ return derived;
71
+ }
72
+
73
+ const engines = new Set();
74
+ let mountedOnce = false;
75
+
76
+ /** True while a derive engine is registered, or the loader holds boot for one. */
77
+ export function derivedActive() {
78
+ return engines.size > 0 || bootPhase.holdSettle;
79
+ }
80
+
81
+ const mountWaiters = [];
82
+
83
+ /** Run every registered engine's repaint, synchronously. Live sync calls it right after a merge applies. */
84
+ export function repaintDerived() {
85
+ for (const repaint of [...engines]) {
86
+ try {
87
+ repaint();
88
+ } catch (err) {
89
+ console.error("clayjs: a derived repaint failed:", err);
90
+ }
91
+ }
92
+ }
93
+
94
+ /** Calls fn once an engine has finished its first pass, or after timeoutMs without one. */
95
+ export function afterDerivedMount(fn, timeoutMs) {
96
+ if (mountedOnce || typeof document === "undefined" || !document.querySelector("[sap]")) return fn();
97
+ let done = false;
98
+ const once = () => {
99
+ if (done) return;
100
+ done = true;
101
+ fn();
102
+ };
103
+ mountWaiters.push(once);
104
+ setTimeout(once, timeoutMs);
105
+ }
106
+
107
+ export const derived = {
108
+ register(repaint) {
109
+ engines.add(repaint);
110
+ return () => engines.delete(repaint);
111
+ },
112
+ mounted() {
113
+ mountedOnce = true;
114
+ for (const fn of mountWaiters.splice(0)) fn();
115
+ },
116
+ };
@@ -71,6 +71,7 @@ import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './
71
71
  import { isPolicyAttribute } from './region-capabilities.js';
72
72
  import { ROOT_LIBRARY_ATTRS, ROUTE_ROOT_PREFIX, ROUTE_ACTIVE_ATTR } from './root-attrs.js';
73
73
  import { isEphemeralAttr } from './ephemeral.js';
74
+ import { isDerivedChange } from './derived.js';
74
75
 
75
76
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
76
77
 
@@ -215,6 +216,7 @@ const localMutation = {
215
216
  // and their legacy equivalents), resolved against the callback's `require`.
216
217
  let filteredChanges = changes.filter(
217
218
  change => {
219
+ if (require !== 'observed' && isDerivedChange(change)) return false;
218
220
  const currentSkipped = skipForPolicy(this._policyForChange(change), require, skip);
219
221
  if (!change.policyTransition) return !currentSkipped;
220
222
  const previousSkipped = skipForPolicy(change.previousPolicy, require, skip);
@@ -647,7 +649,7 @@ const localMutation = {
647
649
  // Runs synchronously in the MO callback; skipped during paused-morph drains.
648
650
  if (!onlyNonPausable && isUserDrivenNow()) {
649
651
  for (const change of changes) {
650
- if (!skipForPolicy(this._policyForChange(change), 'dirty')) {
652
+ if (!isDerivedChange(change) && !skipForPolicy(this._policyForChange(change), 'dirty')) {
651
653
  markUserDriven();
652
654
  break;
653
655
  }
package/src/lib/style.js CHANGED
@@ -7,21 +7,24 @@ import { adoptStyleComparisons } from "./style-adoption.js";
7
7
  // tag in place (or points the saved <link> at the current href), so a page saved
8
8
  // by an older script picks up newer styles. The tag is no-dirty: an update is
9
9
  // carried by the next real save and never counts as an edit, starts an autosave
10
- // or raises the close warning.
11
- const markNoDirty = (el) => {
10
+ // or raises the close warning. A caller can add region tokens (editor-ui keeps a
11
+ // style a plugin re-injects on every boot out of the saved file).
12
+ const marker = (extra = []) => (el) => {
12
13
  const tokens = (el.getAttribute("clay") ?? "").split(/\s+/).filter(Boolean);
13
- if (!tokens.includes("no-dirty")) el.setAttribute("clay", [...tokens, "no-dirty"].join(" "));
14
+ const missing = ["no-dirty", ...extra].filter((token) => !tokens.includes(token));
15
+ if (missing.length) el.setAttribute("clay", [...tokens, ...missing].join(" "));
14
16
  };
17
+ const markNoDirty = marker();
15
18
 
16
19
  export function style(nameOrHref, css) {
17
20
  return writeStyle(nameOrHref, css);
18
21
  }
19
22
 
20
- export function styleWithLegacyId(name, css, legacyId) {
21
- return writeStyle(name, css, legacyId);
23
+ export function styleWithLegacyId(name, css, legacyId, tokens = []) {
24
+ return writeStyle(name, css, legacyId, tokens);
22
25
  }
23
26
 
24
- function writeStyle(nameOrHref, css, legacyId) {
27
+ function writeStyle(nameOrHref, css, legacyId, tokens = []) {
25
28
  const inline = typeof css === "string";
26
29
  const identity = inline ? { name: nameOrHref, legacyId } : { href: getStyleBaseUrl(nameOrHref) };
27
30
  const legacy = legacyId === undefined ? [] : document.querySelectorAll(`style[id="${legacyId}"]:not([data-name])`);
@@ -30,7 +33,7 @@ function writeStyle(nameOrHref, css, legacyId) {
30
33
  adoptStyleComparisons(identity);
31
34
  for (const el of legacy) el.dataset.name = nameOrHref;
32
35
  if (inline) {
33
- const el = insertStyles(nameOrHref, css, markNoDirty);
36
+ const el = insertStyles(nameOrHref, css, marker(tokens));
34
37
  if (legacyId !== undefined) el.id = legacyId;
35
38
  return el;
36
39
  }
@@ -60,6 +60,9 @@ export const PLUGIN_PATHS = {
60
60
  // it replaces. `exclude=source` turns it off.
61
61
  source: { path: "plugins/source.js", editOnly: true, default: true },
62
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 },
63
66
  };
64
67
 
65
68
  // One literal import per module the loader can ask for, so a bundler can see the
@@ -110,11 +113,12 @@ export const MODULES = {
110
113
  "plugins/demo.js": () => import("./plugins/demo.js"),
111
114
  "plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
112
115
  "plugins/source.js": () => import("./plugins/source.js"),
116
+ "plugins/derive.js": () => import("./plugins/derive.js"),
113
117
  };
114
118
 
115
119
  // `source` is last on purpose: its install captures a save clone, so it wants every
116
120
  // plugin that registers a document transform to have registered it first.
117
- const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "page", "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"];
118
122
 
119
123
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
120
124
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
@@ -137,8 +141,11 @@ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "un
137
141
  // It does not bring undo: hyper-undo takes Cmd+Z for the whole window, text fields
138
142
  // included, which a page that never asked for it should not get by default.
139
143
  // 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"] };
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"] };
142
149
 
143
150
  function parseCsv(params, key, enabled, apply) {
144
151
  const raw = params.get(key);
@@ -162,7 +169,7 @@ const DRAFT_DROPS = ["sync", "live", "page", "upload", "quickcrop", "cms", "peop
162
169
  // save, which on a visitor's own draft is a request to the host.
163
170
  const DRAFT_DROPS_CORE = ["attrs/refetch-on-save.js"];
164
171
 
165
- export function resolveModules(params, isEditMode, { isDraftMode = false } = {}) {
172
+ export function resolveModules(params, isEditMode, { isDraftMode = false, hasSap = false } = {}) {
166
173
  const core = [...CORE_WAVES.always];
167
174
  if (isEditMode) core.push(...CORE_WAVES.editOnly);
168
175
 
@@ -175,6 +182,7 @@ export function resolveModules(params, isEditMode, { isDraftMode = false } = {})
175
182
  for (const [name, spec] of Object.entries(PLUGIN_PATHS)) {
176
183
  if (spec.default) enabled.add(name);
177
184
  }
185
+ if (hasSap) enabled.add("derive");
178
186
  // ai-edit cannot run without the wire, so excluding the wire excludes it too.
179
187
  if (excluded.has("wire")) enabled.delete("ai-edit");
180
188
  // A default-on plugin that this mode drops brings nothing with it. An explicitly
package/src/loader.js CHANGED
@@ -4,6 +4,8 @@ import { initTheme } from "./ui/theme.js";
4
4
  import { ephemeral } from "./lib/ephemeral.js";
5
5
  import { effect } from "./lib/effect.js";
6
6
  import { style } from "./lib/style.js";
7
+ import { derived } from "./lib/derived.js";
8
+ import { bootPhase } from "./core/boot-phase.js";
7
9
 
8
10
  function domReady() {
9
11
  return new Promise((resolve) => onDomReady(resolve));
@@ -36,7 +38,9 @@ export async function boot(base, params, readyResolve) {
36
38
 
37
39
  const { isDraftMode } = editMode;
38
40
 
39
- const plan = resolveModules(params, isEditMode, { isDraftMode });
41
+ const hasSap = Boolean(document.querySelector("[sap]"));
42
+ bootPhase.holdSettle = hasSap && isEditMode;
43
+ const plan = resolveModules(params, isEditMode, { isDraftMode, hasSap });
40
44
  const loaded = {};
41
45
 
42
46
  for (const path of plan.core) {
@@ -70,11 +74,36 @@ export async function boot(base, params, readyResolve) {
70
74
  // clay.ready until they finish. allSettled: a failed plugin degrades, never blocks boot.
71
75
  await Promise.allSettled(plan.plugins.map((path) => loaded[path]?.ready).filter(Boolean));
72
76
 
77
+ if (hasSap) mountDerivedEngine(loaded);
78
+
73
79
  installViewModeNotice(isEditMode);
74
80
  readyResolve(window.clay);
75
81
  document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
76
82
  }
77
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
+
78
107
  function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy) {
79
108
  const clay = window.clay;
80
109
 
@@ -100,6 +129,7 @@ function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility
100
129
  clay.readData = pageData.readData;
101
130
  clay.writeData = pageData.writeData;
102
131
  clay.ephemeral = ephemeral;
132
+ clay.derived = derived;
103
133
  clay.effect = effect;
104
134
  clay.style = style;
105
135
 
@@ -556,16 +556,64 @@ export function deleteAtCollapsedEnd(st, dispatch) {
556
556
  return true;
557
557
  }
558
558
 
559
- // Backspace at the start of a paragraph, heading or code block inside a toggle
560
- // body moves it out of the toggle, as Shift-Tab does, instead of joining it to
561
- // the line above. A list item there loses its list format first
562
- // (backspaceListItem), then leaves on the next Backspace.
559
+ // Delete at the end of a line just above a toggle. A plain paragraph's text
560
+ // moves onto the front of the summary, so the toggle keeps its body and open
561
+ // state; the default join would turn the toggle into a paragraph and release
562
+ // its body. Any other line (heading, list item, callout) would lose its format
563
+ // in that merge, so the caret steps into the summary instead.
564
+ export function deleteBeforeToggle(st, dispatch) {
565
+ const { $from, empty } = st.selection;
566
+ if (!empty || $from.depth !== 1) return false;
567
+ const n = $from.parent, i = $from.index(0);
568
+ if (!n.isTextblock || n.type.spec.code || isToggle(n) || !n.content.size || $from.parentOffset !== n.content.size) return false;
569
+ if (i + 1 >= st.doc.childCount || !isToggle(st.doc.child(i + 1))) return false;
570
+ if (dispatch && n.type !== N.paragraph) dispatch(st.tr.setSelection(TextSelection.create(st.doc, $from.after() + 1)).scrollIntoView());
571
+ else if (dispatch) {
572
+ const at = $from.before();
573
+ const tr = st.tr.insert($from.after() + 1, n.content).delete(at, $from.after());
574
+ dispatch(tr.setSelection(TextSelection.create(tr.doc, at + 1 + n.content.size)).scrollIntoView());
575
+ }
576
+ return true;
577
+ }
578
+
579
+ // Backspace and Delete at the edge of a code block never merge text into or
580
+ // out of it. An empty line on the other side is removed; otherwise the caret
581
+ // steps across to the neighbouring line.
582
+ const codeEdge = (dir) => (st, dispatch) => {
583
+ const { $from, empty } = st.selection;
584
+ if (!empty || $from.depth !== 1 || !$from.parent.isTextblock) return false;
585
+ const n = $from.parent, i = $from.index(0);
586
+ if ($from.parentOffset !== (dir < 0 ? 0 : n.content.size)) return false;
587
+ const ents = outline(st.doc);
588
+ const ti = dir < 0 ? collapsedBefore(ents, i) : -1;
589
+ const j = ti >= 0 ? ti : i + dir;
590
+ if (j < 0 || j >= ents.length) return false;
591
+ const o = ents[j].node;
592
+ if (!o.isTextblock || (!n.type.spec.code && !o.type.spec.code)) return false;
593
+ if (!dispatch) return true;
594
+ const tr = st.tr, mine = ents[i], other = ents[j];
595
+ const target = dir < 0 ? other.end - 1 : other.at + 1;
596
+ if (!n.content.size && !n.type.spec.code) tr.delete(mine.at, mine.end).setSelection(TextSelection.create(tr.doc, tr.mapping.map(target)));
597
+ else if (!o.content.size && !o.type.spec.code) tr.delete(other.at, other.end);
598
+ else tr.setSelection(TextSelection.create(tr.doc, target));
599
+ dispatch(tr.scrollIntoView());
600
+ return true;
601
+ };
602
+ export const backspaceCodeEdge = codeEdge(-1);
603
+ export const deleteCodeEdge = codeEdge(1);
604
+
605
+ // Backspace at the start of the first paragraph, heading or code block of a
606
+ // toggle body joins it onto the summary line, which the default join cannot
607
+ // do. Later body blocks are left to the default join with the line above.
563
608
  export function backspaceBodyBlock(st, dispatch) {
564
609
  const { $from, empty } = st.selection;
565
610
  if (!empty || $from.depth !== 1 || $from.parentOffset !== 0 || !$from.parent.isTextblock) return false;
566
611
  const n = $from.parent;
567
612
  if (isList(n) || isToggle(n) || parentOf(n) === null) return false;
568
- return outdentBlocks(st, dispatch);
613
+ const i = $from.index(0);
614
+ if (st.doc.child(i - 1).attrs.id !== parentOf(n)) return false;
615
+ if (dispatch) dispatch(joinIntoSummary(st, outline(st.doc), i - 1, i).scrollIntoView());
616
+ return true;
569
617
  }
570
618
 
571
619
  // ---- open / closed ----------------------------------------------------------
@@ -1095,7 +1143,8 @@ export function pastePlugin() {
1095
1143
 
1096
1144
  // ---- keymap -----------------------------------------------------------------
1097
1145
 
1098
- const backspace = commands.chainCommands(backspaceListItem, backspaceToggle, backspaceBodyBlock, backspaceAfterCollapsed);
1146
+ const backspace = commands.chainCommands(backspaceListItem, backspaceToggle, backspaceCodeEdge, backspaceBodyBlock, backspaceAfterCollapsed);
1147
+ const del = commands.chainCommands(deleteAtCollapsedEnd, deleteBeforeToggle, deleteCodeEdge);
1099
1148
 
1100
1149
  // Modified Backspace and Delete get the same guards as the plain keys, so none
1101
1150
  // of them can reach baseKeymap's joins into a hidden body. Where no guard
@@ -1107,12 +1156,12 @@ export const blockKeys = {
1107
1156
  "Mod-Backspace": backspace,
1108
1157
  "Alt-Backspace": backspace,
1109
1158
  "Ctrl-h": backspace,
1110
- Delete: deleteAtCollapsedEnd,
1111
- "Shift-Delete": deleteAtCollapsedEnd,
1112
- "Mod-Delete": deleteAtCollapsedEnd,
1113
- "Alt-Delete": deleteAtCollapsedEnd,
1114
- "Ctrl-d": deleteAtCollapsedEnd,
1115
- "Ctrl-Alt-Backspace": deleteAtCollapsedEnd,
1159
+ Delete: del,
1160
+ "Shift-Delete": del,
1161
+ "Mod-Delete": del,
1162
+ "Alt-Delete": del,
1163
+ "Ctrl-d": del,
1164
+ "Ctrl-Alt-Backspace": del,
1116
1165
  Tab: indentBlocks,
1117
1166
  "Shift-Tab": outdentBlocks,
1118
1167
  "Mod-Shift-ArrowUp": moveBlock(-1),
@@ -338,7 +338,9 @@ function checkboxClick(view, event) {
338
338
  export function createPageEditor(mount, { blocks = null, onChange = null, upload, highlight, doc: initialDoc, dispatch, history: useHistory = true, undo, redo, editable = true, toolHost: providedToolHost = null, onAiTool = null, reviseTool = null, board = null } = {}) {
339
339
  const doc = mount.ownerDocument;
340
340
  const toolHost = providedToolHost ?? createToolHost({ mode: "edit" });
341
- styleWithLegacyId(STYLE_ID, PAGE_EDITOR_CSS, STYLE_ID);
341
+ // Editor chrome, injected on every edit-mode boot and kept out of the saved file:
342
+ // the reader theme <link> styles the page for visitors.
343
+ styleWithLegacyId(STYLE_ID, PAGE_EDITOR_CSS, STYLE_ID, ["editor-ui"]);
342
344
  const shell = doc.createElement("div");
343
345
  shell.className = "page-shell";
344
346
  shell.setAttribute("clay", "editor-ui");
@@ -0,0 +1,12 @@
1
+ // The sapjs core, for a page with a [sap] root and no full sapjs. It paints the
2
+ // presentations the page declares from its records (text, show, attr:, class:,
3
+ // css:), and ClayJS treats what it paints as derived (lib/derived.js). Markup
4
+ // that needs more is a mount error that names full sapjs. Full sapjs, loaded by
5
+ // its own script tag, takes the page over, and this plugin stays idle.
6
+
7
+ function fullSapjs() {
8
+ const sap = window.Sap;
9
+ return Boolean((sap && !sap.core) || window.clay?.loaded?.sap);
10
+ }
11
+
12
+ export const ready = fullSapjs() ? null : import("../vendor/sap-core.vendor.js").then(() => {});