@panphora/clayjs 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clay.standalone.js +1602 -1168
- package/dist/server/checkpoint.cjs +1 -70690
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -72039
- package/docs/guide.md +35 -8
- package/entries/sap.js +5 -5
- package/package.json +1 -1
- package/packed-contract.json +1 -0
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +1 -1
- package/src/core/save.js +94 -6
- package/src/core/snapshot.js +12 -0
- package/src/lib/derived.js +116 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style.js +10 -7
- package/src/loader-logic.js +12 -4
- package/src/loader.js +31 -1
- package/src/page/blocks.js +61 -12
- package/src/page/editor.js +3 -1
- package/src/plugins/derive.js +12 -0
- package/src/plugins/undo.js +2 -1
- package/src/sync/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +23 -6
- package/src/vendor/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -0
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
|
-
|
|
983
|
+
settle
|
|
902
984
|
);
|
|
903
985
|
|
|
904
986
|
// Max timeout fallback
|
|
905
987
|
setTimeout(() => {
|
|
906
|
-
if (!settled)
|
|
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
|
package/src/core/snapshot.js
CHANGED
|
@@ -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
|
+
};
|
package/src/lib/mutation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
36
|
+
const el = insertStyles(nameOrHref, css, marker(tokens));
|
|
34
37
|
if (legacyId !== undefined) el.id = legacyId;
|
|
35
38
|
return el;
|
|
36
39
|
}
|
package/src/loader-logic.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/src/page/blocks.js
CHANGED
|
@@ -556,16 +556,64 @@ export function deleteAtCollapsedEnd(st, dispatch) {
|
|
|
556
556
|
return true;
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// the
|
|
562
|
-
// (
|
|
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
|
-
|
|
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:
|
|
1111
|
-
"Shift-Delete":
|
|
1112
|
-
"Mod-Delete":
|
|
1113
|
-
"Alt-Delete":
|
|
1114
|
-
"Ctrl-d":
|
|
1115
|
-
"Ctrl-Alt-Backspace":
|
|
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),
|
package/src/page/editor.js
CHANGED
|
@@ -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
|
-
|
|
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(() => {});
|