@panphora/clayjs 1.11.0 → 1.11.2
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 +1802 -1454
- package/entries/sap.js +6 -6
- package/package.json +2 -1
- package/src/attrs/save-freeze.js +6 -3
- package/src/core/persist.js +11 -1
- package/src/core/save.js +63 -8
- package/src/core/snapshot.js +8 -16
- package/src/core/source-map.js +61 -19
- package/src/lib/content-dom.js +58 -28
- package/src/lib/ephemeral.js +88 -0
- package/src/lib/extension-noise.js +11 -4
- package/src/lib/mutation.js +4 -1
- package/src/lib/root-attrs.js +24 -1
- package/src/loader.js +2 -0
- package/src/plugins/undo.js +17 -2
- package/src/sync/live-sync.js +8 -6
- package/src/tailwind.js +12 -0
- package/src/vendor/hyper-morph.vendor.js +3 -3
package/src/core/source-map.js
CHANGED
|
@@ -587,7 +587,16 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
587
587
|
// `from >= 0` is not defensive padding: `src.slice(-1, n)` is the LAST BYTE of the
|
|
588
588
|
// source, so an unset offset reaching here does not produce nothing, it produces one
|
|
589
589
|
// wrong byte and looks like a successful render.
|
|
590
|
-
|
|
590
|
+
// Adjacent copied ranges merge into one piece, except across a mark that records
|
|
591
|
+
// a piece index (tailMark, afterBody): a piece sealed under a mark is never extended,
|
|
592
|
+
// so the next range starts a new piece at the index the mark points to.
|
|
593
|
+
let sealedAt = 0;
|
|
594
|
+
const keep = (from, to) => {
|
|
595
|
+
if (!(from >= 0 && to > from)) return;
|
|
596
|
+
const last = pieces.length > sealedAt ? pieces[pieces.length - 1] : null;
|
|
597
|
+
if (last && last.text === undefined && last.to === from) last.to = to;
|
|
598
|
+
else pieces.push({ from, to });
|
|
599
|
+
};
|
|
591
600
|
const text = (t) => { if (t) pieces.push({ text: t }); };
|
|
592
601
|
// Elements in `opts.print`, and everything inside them, are printed rather than
|
|
593
602
|
// copied: the answer to a region whose copy did not verify.
|
|
@@ -622,7 +631,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
622
631
|
// Called once <body>'s end tag, if any, has been emitted. The owner is still last when
|
|
623
632
|
// nothing but end tags was emitted after its text.
|
|
624
633
|
function endOfBody() {
|
|
625
|
-
afterBody = pieces.length;
|
|
634
|
+
afterBody = pieces.length; sealedAt = pieces.length;
|
|
626
635
|
tailClean = !!tailMark && tailMark.whole
|
|
627
636
|
&& /^(?:<\/[^>]*>)*$/.test(pieces.slice(tailMark.at).map(piece).join(''));
|
|
628
637
|
}
|
|
@@ -660,7 +669,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
660
669
|
// Test switch: copy one named text node's bytes twice, a corruption only the
|
|
661
670
|
// copy path makes, so printing the element around it is the fix.
|
|
662
671
|
if (opts.doubleCopied !== undefined && data === opts.doubleCopied) keep(loc.from, loc.to);
|
|
663
|
-
if (loc.outsideTail) tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true };
|
|
672
|
+
if (loc.outsideTail) { tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true }; sealedAt = pieces.length; }
|
|
664
673
|
return;
|
|
665
674
|
}
|
|
666
675
|
let owner = null;
|
|
@@ -670,7 +679,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
670
679
|
owner = { tail: whole ? loc.outsideTail : '', whole };
|
|
671
680
|
}
|
|
672
681
|
text(RAW_TEXT.has(parentTag) ? data : escText(data));
|
|
673
|
-
if (owner) tailMark = { at: pieces.length, ...owner };
|
|
682
|
+
if (owner) { tailMark = { at: pieces.length, ...owner }; sealedAt = pieces.length; }
|
|
674
683
|
}
|
|
675
684
|
function emitElement(n, loc) {
|
|
676
685
|
const tag = n.localName;
|
|
@@ -719,7 +728,20 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
719
728
|
// clone still has with the same value copies its bytes; a changed one keeps the
|
|
720
729
|
// author's quoting; a removed one takes its leading whitespace with it; a new one is
|
|
721
730
|
// appended just before the `>`.
|
|
731
|
+
// The open tag copies as one range when the clone's attributes are the source's, in
|
|
732
|
+
// the source's order, with the same values: the attribute walk below would copy the
|
|
733
|
+
// same bytes piece by piece.
|
|
734
|
+
function sameAttrs(n, loc) {
|
|
735
|
+
const attrs = n.attributes;
|
|
736
|
+
if (attrs.length !== loc.attrs.length) return false;
|
|
737
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
738
|
+
const a = loc.attrs[i];
|
|
739
|
+
if (a.from < 0 || attrs[i].value !== a.value || attrs[i].name.toLowerCase() !== a.key) return false;
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
722
743
|
function emitOpenTag(n, loc) {
|
|
744
|
+
if (sameAttrs(n, loc)) { keep(loc.openFrom, loc.openTo); return; }
|
|
723
745
|
const live = new Map();
|
|
724
746
|
for (const a of n.attributes) live.set(a.name.toLowerCase(), a);
|
|
725
747
|
const seen = new Set();
|
|
@@ -832,7 +854,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
832
854
|
if (!whole.has(i)) return emitNode(c, parentTag);
|
|
833
855
|
const s = S[owned[i]];
|
|
834
856
|
keep(s.from, s.to);
|
|
835
|
-
if (s.outsideTail) tailMark = { at: pieces.length, tail: s.outsideTail, whole: true };
|
|
857
|
+
if (s.outsideTail) { tailMark = { at: pieces.length, tail: s.outsideTail, whole: true }; sealedAt = pieces.length; }
|
|
836
858
|
};
|
|
837
859
|
const openOf = (c, i) => {
|
|
838
860
|
if (!whole.has(i)) return openChain(c);
|
|
@@ -903,24 +925,31 @@ function lis(vals) {
|
|
|
903
925
|
export function verify(rendered, today, liveDocument, clone = null, sourceErrors = null) {
|
|
904
926
|
const P = new DOMParser();
|
|
905
927
|
const A = P.parseFromString(rendered, 'text/html');
|
|
906
|
-
const B = P.parseFromString(today, 'text/html');
|
|
907
928
|
const outsideA = vOutside(A), outsideLive = vOutside(liveDocument);
|
|
908
929
|
if (outsideA.join('\n') !== outsideLive.join('\n')) return { ok: false, diff: 'document: outside <html> ' + JSON.stringify(outsideA) + ' vs live ' + JSON.stringify(outsideLive) };
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
930
|
+
// The clone oracle first: it is the stronger claim and needs no second parse. The
|
|
931
|
+
// accept set is unchanged (equal to the clone, or equal to today's bytes reparsed);
|
|
932
|
+
// only the order the two are tried in differs, and the oracle named is still the
|
|
933
|
+
// one that passed.
|
|
934
|
+
let at = null;
|
|
935
|
+
let cloneDiff = null;
|
|
912
936
|
if (clone) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
// the way down compared equal.
|
|
917
|
-
const at = [];
|
|
918
|
-
if (!vDiff(A.documentElement, clone, 'html', at)) {
|
|
937
|
+
at = [];
|
|
938
|
+
cloneDiff = vDiff(A.documentElement, clone, 'html', at);
|
|
939
|
+
if (!cloneDiff) {
|
|
919
940
|
const w = vParseErrors(rendered, sourceErrors);
|
|
920
|
-
return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone'
|
|
941
|
+
return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone' };
|
|
921
942
|
}
|
|
922
|
-
return { ok: false, diff, at };
|
|
923
943
|
}
|
|
944
|
+
const B = P.parseFromString(today, 'text/html');
|
|
945
|
+
const diff = vDiff(A.documentElement, B.documentElement, 'html');
|
|
946
|
+
const worse = diff ? null : vParseErrors(rendered, sourceErrors);
|
|
947
|
+
if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
|
|
948
|
+
// `at` is the index path, in the clone, of the node where the render first differs,
|
|
949
|
+
// so a caller can print that element and try again instead of discarding the whole
|
|
950
|
+
// render. It is valid in the clone because every earlier sibling at every level on
|
|
951
|
+
// the way down compared equal.
|
|
952
|
+
if (clone) return { ok: false, diff, at };
|
|
924
953
|
return { ok: false, diff };
|
|
925
954
|
}
|
|
926
955
|
|
|
@@ -1004,14 +1033,27 @@ function vNoscript(a, b, path) {
|
|
|
1004
1033
|
}
|
|
1005
1034
|
function vKids(el) {
|
|
1006
1035
|
const list = el.localName === 'template' && el.content ? el.content.childNodes : el.childNodes;
|
|
1007
|
-
|
|
1036
|
+
const out = [];
|
|
1037
|
+
for (let i = 0; i < list.length; i++) { const n = list[i]; if (n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8) out.push(n); }
|
|
1038
|
+
return out;
|
|
1008
1039
|
}
|
|
1009
1040
|
const vAttrs = (el) => Array.from(el.attributes, (a) => (a.namespaceURI || '') + ' ' + a.name + '=' + JSON.stringify(a.value)).sort().join('; ');
|
|
1041
|
+
// Equal attribute lists in the same order need no sorted strings; any other case is
|
|
1042
|
+
// decided by the strings, as before.
|
|
1043
|
+
function sameAttrsInOrder(a, b) {
|
|
1044
|
+
const A = a.attributes, B = b.attributes;
|
|
1045
|
+
if (A.length !== B.length) return false;
|
|
1046
|
+
for (let i = 0; i < A.length; i++) {
|
|
1047
|
+
const x = A[i], y = B[i];
|
|
1048
|
+
if (x.name !== y.name || x.value !== y.value || x.namespaceURI !== y.namespaceURI) return false;
|
|
1049
|
+
}
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1010
1052
|
function vDiff(a, b, path, at = []) {
|
|
1011
1053
|
if (a.nodeType !== b.nodeType) return path + ': node type ' + a.nodeType + ' vs ' + b.nodeType;
|
|
1012
1054
|
if (a.nodeType === 3 || a.nodeType === 8) return a.data === b.data ? null : path + ': text ' + JSON.stringify(a.data.slice(0, 60)) + ' vs ' + JSON.stringify(b.data.slice(0, 60));
|
|
1013
1055
|
if (a.localName !== b.localName || a.namespaceURI !== b.namespaceURI) return path + ': tag ' + a.localName + ' vs ' + b.localName;
|
|
1014
|
-
if (vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
|
|
1056
|
+
if (!sameAttrsInOrder(a, b) && vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
|
|
1015
1057
|
if (a.localName === 'noscript' && a.namespaceURI === XHTML) return vNoscript(a, b, path);
|
|
1016
1058
|
const ka = vKids(a), kb = vKids(b);
|
|
1017
1059
|
if (ka.length !== kb.length) return path + ': ' + ka.length + ' vs ' + kb.length + ' children';
|
package/src/lib/content-dom.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capabilitySelector, hasCapability } from './region-capabilities.js'
|
|
1
|
+
import { capabilitySelector, hasCapability, CAPABILITIES, expandsTo, hasPolicyToken } from './region-capabilities.js'
|
|
2
2
|
|
|
3
3
|
const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
|
|
4
4
|
|
|
@@ -7,10 +7,9 @@ function copyControlState(source, copy) {
|
|
|
7
7
|
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
8
8
|
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
|
|
9
9
|
if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
|
|
10
|
+
// Each surviving option carries its own selection, so a <select> whose excluded
|
|
11
|
+
// options were removed from the copy keeps the rest in step with the page.
|
|
10
12
|
if (source.tagName === 'OPTION') copy.selected = source.selected
|
|
11
|
-
if (source.tagName === 'SELECT') {
|
|
12
|
-
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
13
|
-
}
|
|
14
13
|
if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
|
|
15
14
|
}
|
|
16
15
|
|
|
@@ -25,46 +24,77 @@ function matchesWithin(node, selector, boundary, inherit) {
|
|
|
25
24
|
return false
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
// One native deep clone, then a lockstep walk of the two isomorphic trees that
|
|
28
|
+
// records provenance, collects controls, and marks excluded subtrees for removal.
|
|
29
|
+
// The browser's clone is far cheaper than importing node by node. The root answers
|
|
30
|
+
// for its ancestors once; below it the walk is top-down, so with inheritance each
|
|
31
|
+
// element answers only for its own markers: an excluded ancestor is never entered.
|
|
32
|
+
function excluded(source, capability, capabilityMatch, exclude, boundary, inherit) {
|
|
33
|
+
return (inherit
|
|
34
|
+
? hasCapability(source, capability)
|
|
35
|
+
: matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
36
|
+
!!(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The test closestWithCapability makes at each level, for this element alone.
|
|
40
|
+
function hasOwnCapability(element, capability) {
|
|
41
|
+
const definition = CAPABILITIES[capability]
|
|
42
|
+
return definition.tokens.some(token => expandsTo(element, token)) ||
|
|
43
|
+
definition.bundles.some(bundle => hasPolicyToken(element, bundle))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'])
|
|
47
|
+
|
|
48
|
+
function pairTree(source, copy, maps, drop) {
|
|
49
|
+
if (source.nodeType === 1 && drop(source)) { maps.dropped.push(copy); return }
|
|
36
50
|
maps.cloneToLive.set(copy, source)
|
|
37
51
|
maps.liveToClone.set(source, copy)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
maps.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
copyChildren.
|
|
48
|
-
|
|
52
|
+
let sourceChildren = source
|
|
53
|
+
let copyChildren = copy
|
|
54
|
+
if (source.nodeType === 1) {
|
|
55
|
+
if (CONTROL_TAGS.has(source.tagName)) maps.controls.push(source, copy)
|
|
56
|
+
if (source.tagName === 'TEMPLATE') {
|
|
57
|
+
// The deep clone also copied the template's own child nodes, which are not its
|
|
58
|
+
// content and were never part of the view.
|
|
59
|
+
while (copy.firstChild) copy.removeChild(copy.firstChild)
|
|
60
|
+
sourceChildren = source.content
|
|
61
|
+
copyChildren = copy.content
|
|
62
|
+
maps.cloneToLive.set(copyChildren, sourceChildren)
|
|
63
|
+
maps.liveToClone.set(sourceChildren, copyChildren)
|
|
49
64
|
}
|
|
65
|
+
} else if (source.nodeType !== 11) return
|
|
66
|
+
let c = copyChildren.firstChild
|
|
67
|
+
for (let s = sourceChildren.firstChild; s && c; s = s.nextSibling) {
|
|
68
|
+
pairTree(s, c, maps, drop)
|
|
69
|
+
c = c.nextSibling
|
|
50
70
|
}
|
|
51
|
-
if (source.nodeType === 1) copyControlState(source, copy)
|
|
52
|
-
return copy
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
|
|
73
|
+
export function createContentView(context, { capability = 'data', exclude = null, inherit = true, provenance = null } = {}) {
|
|
56
74
|
if (!context) throw new TypeError('createContentView requires a DOM context')
|
|
57
75
|
const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
|
|
58
76
|
if (!sourceDocument?.implementation?.createHTMLDocument) {
|
|
59
77
|
throw new TypeError('createContentView requires an HTML DOM implementation')
|
|
60
78
|
}
|
|
61
79
|
const inertDocument = sourceDocument.implementation.createHTMLDocument('')
|
|
62
|
-
const cloneToLive = new WeakMap()
|
|
80
|
+
const cloneToLive = provenance || new WeakMap()
|
|
63
81
|
const liveToClone = new WeakMap()
|
|
64
82
|
const sourceRoot = context.nodeType === 9 ? context.documentElement : context
|
|
65
83
|
if (exclude) inertDocument.documentElement.matches(exclude)
|
|
66
84
|
const capabilityMatch = capabilitySelector(capability)
|
|
67
|
-
const
|
|
85
|
+
const drop = inherit
|
|
86
|
+
? (el) => hasOwnCapability(el, capability) || !!(exclude && el.matches(exclude))
|
|
87
|
+
: (el) => excluded(el, capability, capabilityMatch, exclude, sourceRoot, false)
|
|
88
|
+
let root = null
|
|
89
|
+
if (sourceRoot.nodeType !== 1 || !excluded(sourceRoot, capability, capabilityMatch, exclude, sourceRoot, inherit)) {
|
|
90
|
+
root = inertDocument.importNode(sourceRoot, true)
|
|
91
|
+
const maps = { cloneToLive, liveToClone, dropped: [], controls: [] }
|
|
92
|
+
pairTree(sourceRoot, root, maps, drop)
|
|
93
|
+
for (const node of maps.dropped) node.remove()
|
|
94
|
+
// Control state is copied once the copy holds only what the view keeps, so a
|
|
95
|
+
// <select> sees exactly the options the old node-by-node import gave it.
|
|
96
|
+
for (let i = 0; i < maps.controls.length; i += 2) copyControlState(maps.controls[i], maps.controls[i + 1])
|
|
97
|
+
}
|
|
68
98
|
if (context.nodeType === 9 && root) {
|
|
69
99
|
inertDocument.replaceChild(root, inertDocument.documentElement)
|
|
70
100
|
liveToClone.set(context, inertDocument)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isPolicyAttribute } from "./region-capabilities.js";
|
|
2
|
+
|
|
3
|
+
// Tab-local state on saved elements. The ephemeral attribute holds words a
|
|
4
|
+
// script sets for this tab (selected, dragging), and an ephemeral:NAME marker
|
|
5
|
+
// makes the element's own NAME attribute tab-local too. A dialog's open is
|
|
6
|
+
// tab-local with no marker: showModal() sets it, and a dialog saved open is
|
|
7
|
+
// always a bug. Like route-active, none of it is saved, synced, undone or
|
|
8
|
+
// counted as a change. The marker itself is ordinary saved content.
|
|
9
|
+
|
|
10
|
+
export const EPHEMERAL_ATTR = "ephemeral";
|
|
11
|
+
const MARKER = "ephemeral:";
|
|
12
|
+
// Identity, region attributes (a marker must not switch a region off) and form
|
|
13
|
+
// state (persist owns those) can never be ephemeral.
|
|
14
|
+
const NEVER = new Set([
|
|
15
|
+
"class", "id", "data-id", "value", "checked", "selected",
|
|
16
|
+
"save-remove", "save-ignore", "save-freeze", "mutations-ignore", "snapshot-remove",
|
|
17
|
+
]);
|
|
18
|
+
const never = (name) => NEVER.has(name) || isPolicyAttribute(name);
|
|
19
|
+
|
|
20
|
+
export function isEphemeralAttr(name, el) {
|
|
21
|
+
if (name === EPHEMERAL_ATTR) return true;
|
|
22
|
+
if (!name || !el || el.nodeType !== 1 || never(name) || name.startsWith(MARKER)) return false;
|
|
23
|
+
if (name === "open" && el.localName === "dialog") return true;
|
|
24
|
+
return el.hasAttribute(MARKER + name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when `el` carries an ephemeral:NAME marker for `name`. */
|
|
28
|
+
export function isMarkedAttr(name, el) {
|
|
29
|
+
return !!el && el.nodeType === 1 && el.hasAttribute(MARKER + name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripElement(el) {
|
|
33
|
+
if (el.localName === "dialog" && el.hasAttribute("open")) el.removeAttribute("open");
|
|
34
|
+
if (el.hasAttribute(EPHEMERAL_ATTR)) el.removeAttribute(EPHEMERAL_ATTR);
|
|
35
|
+
const attrs = el.attributes;
|
|
36
|
+
let names = null;
|
|
37
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
38
|
+
const name = attrs[i].name;
|
|
39
|
+
if (!name.startsWith(MARKER)) continue;
|
|
40
|
+
const target = name.slice(MARKER.length);
|
|
41
|
+
if (target && !never(target)) (names || (names = [])).push(target);
|
|
42
|
+
}
|
|
43
|
+
if (names) for (const name of names) el.removeAttribute(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove ephemeral state from a snapshot clone, in place: the root, every descendant, and template contents. */
|
|
47
|
+
export function stripEphemeral(root) {
|
|
48
|
+
if (!root || root.nodeType !== 1) return;
|
|
49
|
+
stripElement(root);
|
|
50
|
+
for (const el of root.querySelectorAll("*")) {
|
|
51
|
+
stripElement(el);
|
|
52
|
+
if (el.localName === "template" && el.content) {
|
|
53
|
+
for (const child of el.content.children) stripEphemeral(child);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (root.localName === "template" && root.content) {
|
|
57
|
+
for (const child of root.content.children) stripEphemeral(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function words(el) {
|
|
62
|
+
return (el.getAttribute(EPHEMERAL_ATTR) || "").split(/\s+/).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function write(el, list) {
|
|
66
|
+
if (list.length) el.setAttribute(EPHEMERAL_ATTR, list.join(" "));
|
|
67
|
+
else el.removeAttribute(EPHEMERAL_ATTR);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const ephemeral = {
|
|
71
|
+
has(el, word) {
|
|
72
|
+
return words(el).includes(word);
|
|
73
|
+
},
|
|
74
|
+
add(el, word) {
|
|
75
|
+
const list = words(el);
|
|
76
|
+
if (!list.includes(word)) write(el, [...list, word]);
|
|
77
|
+
},
|
|
78
|
+
remove(el, word) {
|
|
79
|
+
const list = words(el);
|
|
80
|
+
if (list.includes(word)) write(el, list.filter((w) => w !== word));
|
|
81
|
+
},
|
|
82
|
+
toggle(el, word, force) {
|
|
83
|
+
const on = force === undefined ? !ephemeral.has(el, word) : !!force;
|
|
84
|
+
if (on) ephemeral.add(el, word);
|
|
85
|
+
else ephemeral.remove(el, word);
|
|
86
|
+
return on;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -55,12 +55,19 @@ export function stripExtensionNoise(root) {
|
|
|
55
55
|
// querySelectorAll('*') never returns the root itself, so an extension stamp on
|
|
56
56
|
// <html> used to ride into every saved file. The root is the one element an
|
|
57
57
|
// extension can mark on a page with no other match.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// The attribute list is only copied when a marker is found on it.
|
|
59
|
+
const strip = (el) => {
|
|
60
|
+
const attrs = el.attributes
|
|
61
|
+
if (!attrs) return
|
|
62
|
+
let found = null
|
|
63
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
64
|
+
const name = attrs[i].name
|
|
65
|
+
if (EXTENSION_ATTR_PATTERN.test(name.toLowerCase())) (found || (found = [])).push(name)
|
|
62
66
|
}
|
|
67
|
+
if (found) for (const name of found) el.removeAttribute(name)
|
|
63
68
|
}
|
|
69
|
+
strip(root)
|
|
70
|
+
for (const el of root.querySelectorAll('*')) strip(el)
|
|
64
71
|
} catch (e) {
|
|
65
72
|
if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
|
|
66
73
|
}
|
package/src/lib/mutation.js
CHANGED
|
@@ -70,6 +70,7 @@ import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
|
|
|
70
70
|
import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
|
|
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
|
+
import { isEphemeralAttr } from './ephemeral.js';
|
|
73
74
|
|
|
74
75
|
import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
|
|
75
76
|
|
|
@@ -83,13 +84,15 @@ const dummyElem = document.createElement("div");
|
|
|
83
84
|
// instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
|
|
84
85
|
// without this every save would push a savestatus flip into undo's record stream
|
|
85
86
|
// and Cmd+Z after a save would undo that instead of the user's edit.
|
|
86
|
-
// Router state (route-* on the root, route-active anywhere)
|
|
87
|
+
// Router state (route-* on the root, route-active anywhere) and ephemeral state
|
|
88
|
+
// (lib/ephemeral.js) are dropped here for the same reason.
|
|
87
89
|
const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
|
|
88
90
|
|
|
89
91
|
function isLibraryRootAttr(record) {
|
|
90
92
|
if (record.type !== 'attributes') return false;
|
|
91
93
|
const name = record.attributeName;
|
|
92
94
|
if (name === ROUTE_ACTIVE_ATTR) return true;
|
|
95
|
+
if (isEphemeralAttr(name, record.target)) return true;
|
|
93
96
|
return record.target === document.documentElement
|
|
94
97
|
&& (ROOT_LIBRARY_ATTR_SET.has(name) || name.startsWith(ROUTE_ROOT_PREFIX));
|
|
95
98
|
}
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* peer's copy of them must never be applied.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { isEphemeralAttr, isMarkedAttr } from "./ephemeral.js";
|
|
12
|
+
|
|
11
13
|
// The save token, in the order a reader tries the two spellings. Spec §9 bounds it to
|
|
12
14
|
// per-file and per-tab and makes the host strip it before writing, so it never reaches
|
|
13
15
|
// disk. But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
@@ -128,15 +130,36 @@ export const ROUTE_ACTIVE_ATTR = "route-active";
|
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
132
|
* True when `name` on `el` belongs to this tab rather than to the document:
|
|
131
|
-
* every tab-local root attribute, route-* on a root,
|
|
133
|
+
* every tab-local root attribute, route-* on a root, route-active anywhere, and
|
|
134
|
+
* ephemeral state (lib/ephemeral.js) anywhere.
|
|
132
135
|
* Works on the live root and on a snapshot clone's or parsed frame's root.
|
|
133
136
|
*/
|
|
134
137
|
export function isTabLocalAttr(name, el) {
|
|
138
|
+
if (isEphemeralAttr(name, el)) return true;
|
|
135
139
|
if (name === ROUTE_ACTIVE_ATTR) return true;
|
|
136
140
|
if (!el || el.localName !== "html" || el.parentElement) return false;
|
|
137
141
|
return TAB_LOCAL_ROOT_ATTRS.has(name) || name.startsWith(ROUTE_ROOT_PREFIX);
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
/**
|
|
145
|
+
* hyper-morph's ignoreAttribute for tab-local state. While a merge is applied,
|
|
146
|
+
* `merged` is the element the live one is becoming, so a frame that adds or
|
|
147
|
+
* removes an ephemeral:NAME marker together with NAME is judged by the marker
|
|
148
|
+
* as it ends up, not by attribute order.
|
|
149
|
+
*/
|
|
150
|
+
export function mergeIgnoresAttr(el, name, merged) {
|
|
151
|
+
return isTabLocalAttr(name, merged || el);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* hyper-morph's beforeAttributeUpdated veto: a merge may neither write nor
|
|
156
|
+
* remove a tab-local attribute. A marker-protected attribute is left to
|
|
157
|
+
* mergeIgnoresAttr, which reads the marker from the merged element.
|
|
158
|
+
*/
|
|
159
|
+
export function mergeVetoesAttr(name, el) {
|
|
160
|
+
return isTabLocalAttr(name, el) && !isMarkedAttr(name, el) ? false : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
140
163
|
/** Remove router state from a snapshot clone: route-* on its root, route-active anywhere. */
|
|
141
164
|
export function stripRouteAttrs(root) {
|
|
142
165
|
for (const attr of [...root.attributes]) {
|
package/src/loader.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveModules, MODULES } from "./loader-logic.js";
|
|
2
2
|
import onDomReady from "./lib/dom-ready.js";
|
|
3
3
|
import { initTheme } from "./ui/theme.js";
|
|
4
|
+
import { ephemeral } from "./lib/ephemeral.js";
|
|
4
5
|
|
|
5
6
|
function domReady() {
|
|
6
7
|
return new Promise((resolve) => onDomReady(resolve));
|
|
@@ -91,6 +92,7 @@ function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPol
|
|
|
91
92
|
|
|
92
93
|
clay.readData = pageData.readData;
|
|
93
94
|
clay.writeData = pageData.writeData;
|
|
95
|
+
clay.ephemeral = ephemeral;
|
|
94
96
|
|
|
95
97
|
if (save) {
|
|
96
98
|
const saveFn = save.savePage || save.default;
|
package/src/plugins/undo.js
CHANGED
|
@@ -5,15 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
import { undo } from '../vendor/hyper-undo.vendor.js'
|
|
7
7
|
import { isEditMode } from '../core/is-edit-mode.js'
|
|
8
|
+
import { isTabLocalAttr } from '../lib/root-attrs.js'
|
|
9
|
+
|
|
10
|
+
// Tab-local attributes (ephemeral state, route-active, root library state) are
|
|
11
|
+
// never undone. The shared mutation hub already drops them for the main
|
|
12
|
+
// instance; a scope made with create() runs its own observer, so it is told too.
|
|
13
|
+
const withTabLocal = (opts = {}) => {
|
|
14
|
+
const own = opts.ignoreAttribute
|
|
15
|
+
return {
|
|
16
|
+
...opts,
|
|
17
|
+
ignoreAttribute: (name, target) => isTabLocalAttr(name, target) || (own ? own(name, target) : false),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const createScope = undo.create.bind(undo)
|
|
22
|
+
undo.create = (opts) => createScope(withTabLocal(opts))
|
|
8
23
|
|
|
9
24
|
function init() {
|
|
10
25
|
if (!isEditMode) return
|
|
11
26
|
// Defer until the body exists so the default scope (document.body) is valid.
|
|
12
27
|
if (typeof document === 'undefined') return
|
|
13
28
|
if (document.readyState === 'loading') {
|
|
14
|
-
document.addEventListener('DOMContentLoaded', () => undo.start({ bindKeys: true }))
|
|
29
|
+
document.addEventListener('DOMContentLoaded', () => undo.start(withTabLocal({ bindKeys: true })))
|
|
15
30
|
} else {
|
|
16
|
-
undo.start({ bindKeys: true })
|
|
31
|
+
undo.start(withTabLocal({ bindKeys: true }))
|
|
17
32
|
}
|
|
18
33
|
}
|
|
19
34
|
|
package/src/sync/live-sync.js
CHANGED
|
@@ -40,11 +40,11 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
|
40
40
|
const { createIdentityStore } = HyperMorph;
|
|
41
41
|
import Mutation from "../lib/mutation.js";
|
|
42
42
|
import { NO_DIRTY_SELECTOR, LIVE_SELECTOR } from "../lib/region-policy.js";
|
|
43
|
-
import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
|
|
43
|
+
import { EXTENSION_NODE_SELECTOR, EXTENSION_ATTR_PATTERN } from '../lib/extension-noise.js';
|
|
44
44
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
45
45
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
46
46
|
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode, captureForMerge, captureForSaveAndComparison } from '../core/snapshot.js';
|
|
47
|
-
import {
|
|
47
|
+
import { mergeIgnoresAttr, mergeVetoesAttr } from '../lib/root-attrs.js';
|
|
48
48
|
import { autosaveActive } from '../lib/autosave-state.js';
|
|
49
49
|
import { enableContentEditable } from '../core/admin-contenteditable.js';
|
|
50
50
|
import { enableOnClick } from '../core/admin-onclick.js';
|
|
@@ -101,9 +101,12 @@ const REMOTE_WINS_SELECTOR = [
|
|
|
101
101
|
'[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
|
|
102
102
|
].join(',');
|
|
103
103
|
|
|
104
|
-
// Host tokens, library state and
|
|
104
|
+
// Host tokens, library state, router state and ephemeral state, on every side
|
|
105
105
|
// of a merge: the live root, a snapshot clone's root, a parsed frame's root.
|
|
106
|
-
|
|
106
|
+
// Extension marker attributes too: snapshots and frames never carry them, so a
|
|
107
|
+
// disk frame would otherwise strip them from the live inputs they belong to.
|
|
108
|
+
const isRootLocalAttr = (el, name, merged) =>
|
|
109
|
+
mergeIgnoresAttr(el, name, merged) || EXTENSION_ATTR_PATTERN.test(name.toLowerCase());
|
|
107
110
|
|
|
108
111
|
// Identity an author wrote into the page, the fallback behind synthetic ids.
|
|
109
112
|
//
|
|
@@ -1655,8 +1658,7 @@ class LiveSync {
|
|
|
1655
1658
|
const store = this.identity;
|
|
1656
1659
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1657
1660
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
1658
|
-
const beforeAttributeUpdated =
|
|
1659
|
-
isTabLocalAttr(name, element) ? false : undefined;
|
|
1661
|
+
const beforeAttributeUpdated = mergeVetoesAttr;
|
|
1660
1662
|
const authored = uniqueAuthoredIdentity(new WeakMap());
|
|
1661
1663
|
const sideIdentity = (map) =>
|
|
1662
1664
|
isIdMap(map) ? { first: authored, map, then: authored } : authored;
|
package/src/tailwind.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Tailwind v4 plugin for ClayJS ephemeral state:
|
|
2
|
+
// @plugin "@panphora/clayjs/tailwind";
|
|
3
|
+
// ephemeral-selected:ring-2 -> &[ephemeral~="selected"]. matchVariant only
|
|
4
|
+
// accepts bare names that `values` lists, so `values` answers for any simple
|
|
5
|
+
// word; the bracketed form ephemeral-[selected]: works regardless.
|
|
6
|
+
const anyWord = new Proxy({}, {
|
|
7
|
+
get: (_, key) => (typeof key === "string" && /^[a-z0-9_-]+$/i.test(key) ? key : undefined),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export default function ephemeralVariant({ matchVariant }) {
|
|
11
|
+
matchVariant("ephemeral", (value) => `&[ephemeral~="${value}"]`, { values: anyWord });
|
|
12
|
+
}
|