@panphora/clayjs 0.1.0 → 0.2.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/NOTICE +19 -0
- package/README.md +2 -0
- package/_headers +2 -0
- package/all.js +24 -0
- package/clay-data.js +16 -0
- package/clay-dom.js +18 -0
- package/clay-events.js +28 -0
- package/clay-options.js +27 -0
- package/clay-ui.js +29 -0
- package/clay-utils.js +18 -0
- package/clay.js +17 -5
- package/package.json +25 -2
- package/sap.js +16 -0
- package/src/core/save.js +5 -4
- package/src/core/snapshot.js +12 -9
- package/src/dom/all.js +440 -0
- package/src/dom/dom-helpers.js +218 -0
- package/src/dom/form-data.js +62 -0
- package/src/dom/insert-style-tag.js +109 -0
- package/src/dom/nearest.js +97 -0
- package/src/dom/pipe.js +1 -0
- package/src/events/index.js +22 -0
- package/src/events/onclickaway.js +27 -0
- package/src/events/onclickchildren.js +12 -0
- package/src/events/onclone.js +54 -0
- package/src/events/onmutation.js +54 -0
- package/src/events/onpagemutation.js +37 -0
- package/src/events/onrender.js +32 -0
- package/src/lib/mutation.js +26 -9
- package/src/lib/on-load.js +9 -0
- package/src/loader-logic.js +2 -1
- package/src/loader.js +2 -0
- package/src/options/options.js +253 -0
- package/src/plugins/demo.js +124 -0
- package/src/sync/live-sync.js +61 -19
- package/src/sync/merge-tags.js +36 -0
- package/src/ui/dialogs.js +206 -0
- package/src/ui/index.js +38 -0
- package/src/ui/modal.js +884 -0
- package/src/ui/toast.js +434 -0
- package/src/utils/copy-to-clipboard.js +27 -0
- package/src/utils/debounce.js +23 -0
- package/src/utils/index.js +10 -0
- package/src/utils/slugify.js +20 -0
- package/src/vendor/hyper-morph.vendor.js +2 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option Visibility
|
|
3
|
+
*
|
|
4
|
+
* Shows/hides elements based on `option:` and `option-not:` attributes.
|
|
5
|
+
*
|
|
6
|
+
* SYNTAX:
|
|
7
|
+
* option:name="value" - Show when ancestor has name="value"
|
|
8
|
+
* show-when:name="value" - Same as option: (the canonical, sapjs-aligned spelling)
|
|
9
|
+
* option:name="a|b|c" - Show when ancestor has name="a" OR "b" OR "c"
|
|
10
|
+
* option:name="" - Show when ancestor has name="" (empty value)
|
|
11
|
+
* option:name="|saved" - Show when ancestor has name="" OR name="saved"
|
|
12
|
+
* hide-when:name="value" - Inverse of show-when: hide WHEN ancestor has name="value"
|
|
13
|
+
* option-not:name="value" - Show when ancestor has name attr but ≠ "value"
|
|
14
|
+
* option-not:name="a|b" - Show when ancestor has name attr but ≠ "a" AND ≠ "b"
|
|
15
|
+
*
|
|
16
|
+
* show-when:/hide-when: are honored identically here and in sapjs, so visibility
|
|
17
|
+
* authored against either library just works. option:/option-not: stay permanent
|
|
18
|
+
* aliases.
|
|
19
|
+
*
|
|
20
|
+
* EXAMPLES:
|
|
21
|
+
* <div view="kanban">
|
|
22
|
+
* <button option:view="kanban">Visible when view=kanban</button>
|
|
23
|
+
* <button option:view="list">Hidden</button>
|
|
24
|
+
* </div>
|
|
25
|
+
*
|
|
26
|
+
* The ancestor carries the plain attribute (view="kanban"); descendants opt in with
|
|
27
|
+
* option:view / option-not:view. editmode/pageowner are stamped on <html> by clay.js,
|
|
28
|
+
* so option:/show-when:editmode is an edit-mode bridge that sapjs honors too.
|
|
29
|
+
*
|
|
30
|
+
* <div savestatus="error">
|
|
31
|
+
* <span option:savestatus="saved|error">Visible (matches error)</span>
|
|
32
|
+
* <span option-not:savestatus="saving">Visible (error ≠ saving)</span>
|
|
33
|
+
* </div>
|
|
34
|
+
*
|
|
35
|
+
* HOW IT WORKS:
|
|
36
|
+
* Uses a single conditional-hide rule per pattern. Elements get `display: none !important`
|
|
37
|
+
* ONLY when they are NOT inside a matching ancestor scope. When the ancestor condition
|
|
38
|
+
* IS met, the hide rule doesn't match, so the author's original display value
|
|
39
|
+
* (flex, grid, block, etc.) applies naturally — no recovery needed.
|
|
40
|
+
*
|
|
41
|
+
* BROWSER SUPPORT:
|
|
42
|
+
* Requires `:is()` and `:not()` with selector lists (~96% of browsers, 2021+).
|
|
43
|
+
* Falls back gracefully - elements remain visible if unsupported.
|
|
44
|
+
*
|
|
45
|
+
* TRADEOFFS:
|
|
46
|
+
* - Pro: Pure CSS after generation, zero JS overhead for toggling
|
|
47
|
+
* - Pro: No @layer or revert-layer — works with any author CSS (layered or unlayered)
|
|
48
|
+
* - Pro: One rule per pattern instead of two
|
|
49
|
+
* - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import Mutation from "../lib/mutation.js";
|
|
53
|
+
import insertStyles from "../dom/insert-style-tag.js";
|
|
54
|
+
|
|
55
|
+
const STYLE_NAME = 'option-visibility';
|
|
56
|
+
|
|
57
|
+
// The visibility prefixes, longest-disambiguating first. show-when/option are the
|
|
58
|
+
// "show when match" pair; hide-when is its inverse; option-not is "present but not
|
|
59
|
+
// match". All four compile to ancestor-attribute CSS below.
|
|
60
|
+
const OPTION_PREFIXES = [
|
|
61
|
+
{ prefix: 'option-not', kind: 'show-not' },
|
|
62
|
+
{ prefix: 'option', kind: 'show' },
|
|
63
|
+
{ prefix: 'show-when', kind: 'show' },
|
|
64
|
+
{ prefix: 'hide-when', kind: 'hide' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an option:/option-not:/show-when:/hide-when: attribute into a pattern object.
|
|
69
|
+
* Pure function for easy testing.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} attrName - Attribute name (e.g., 'show-when:editmode', 'option-not:status')
|
|
72
|
+
* @param {string} attrValue - Attribute value (e.g., 'true', 'a|b|c')
|
|
73
|
+
* @returns {Object|null} Pattern object or null if not a valid visibility attribute
|
|
74
|
+
*/
|
|
75
|
+
export function parseOptionAttribute(attrName, attrValue) {
|
|
76
|
+
let match = null;
|
|
77
|
+
for (const p of OPTION_PREFIXES) {
|
|
78
|
+
if (attrName.startsWith(p.prefix + ':')) { match = p; break; }
|
|
79
|
+
}
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
|
|
82
|
+
const name = attrName.slice(match.prefix.length + 1);
|
|
83
|
+
const rawValue = attrValue;
|
|
84
|
+
// Split by pipe, keep empty strings (they match empty attribute values)
|
|
85
|
+
const values = rawValue.split('|');
|
|
86
|
+
|
|
87
|
+
return { name, rawValue, values, kind: match.kind, prefix: match.prefix, negated: match.kind === 'show-not' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const optionVisibility = {
|
|
91
|
+
debug: false,
|
|
92
|
+
_started: false,
|
|
93
|
+
_unsubscribe: null,
|
|
94
|
+
|
|
95
|
+
log(...args) {
|
|
96
|
+
if (this.debug) console.log('[OptionVisibility]', ...args);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find all unique option:/option-not: patterns using XPath
|
|
101
|
+
* Returns array of { name, rawValue, values, negated }
|
|
102
|
+
*/
|
|
103
|
+
findOptionAttributes() {
|
|
104
|
+
const patterns = new Map();
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const snapshot = document.evaluate(
|
|
108
|
+
'//*[@*[starts-with(name(), "option:") or starts-with(name(), "option-not:") or starts-with(name(), "show-when:") or starts-with(name(), "hide-when:")]]',
|
|
109
|
+
document.documentElement,
|
|
110
|
+
null,
|
|
111
|
+
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
|
|
112
|
+
null
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < snapshot.snapshotLength; i++) {
|
|
116
|
+
const el = snapshot.snapshotItem(i);
|
|
117
|
+
for (const attr of el.attributes) {
|
|
118
|
+
const pattern = parseOptionAttribute(attr.name, attr.value);
|
|
119
|
+
if (!pattern) continue;
|
|
120
|
+
|
|
121
|
+
// key includes the prefix so each authored spelling gets its own rule
|
|
122
|
+
// (option:tab and show-when:tab target different attributes, same scope).
|
|
123
|
+
const key = `${pattern.prefix}:${pattern.name}=${pattern.rawValue}`;
|
|
124
|
+
if (!patterns.has(key)) {
|
|
125
|
+
patterns.set(key, pattern);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
this.log('XPath error, falling back to empty', error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [...patterns.values()];
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate CSS rules for conditional hiding.
|
|
138
|
+
*
|
|
139
|
+
* Each pattern produces a single rule that hides the element ONLY when
|
|
140
|
+
* it's NOT inside a matching ancestor scope. When the condition IS met,
|
|
141
|
+
* the rule doesn't match, so the author's original display value applies.
|
|
142
|
+
*/
|
|
143
|
+
generateCSS(patterns) {
|
|
144
|
+
if (!patterns.length) return '';
|
|
145
|
+
|
|
146
|
+
return patterns.map((pattern) => {
|
|
147
|
+
const { name, rawValue, values } = pattern;
|
|
148
|
+
// Tolerate the legacy {negated} pattern shape so external callers keep working.
|
|
149
|
+
const kind = pattern.kind || (pattern.negated ? 'show-not' : 'show');
|
|
150
|
+
const prefix = pattern.prefix || (kind === 'show-not' ? 'option-not' : kind === 'hide' ? 'hide-when' : 'option');
|
|
151
|
+
const safeName = CSS.escape(name);
|
|
152
|
+
const safeRawValue = CSS.escape(rawValue);
|
|
153
|
+
const safePrefix = CSS.escape(prefix);
|
|
154
|
+
const attrSelector = `[${safePrefix}\\:${safeName}="${safeRawValue}"]`;
|
|
155
|
+
|
|
156
|
+
// The "matching scope": an ancestor (or self) carries name="value".
|
|
157
|
+
const self = values.map(v => `[${safeName}="${CSS.escape(v)}"]`);
|
|
158
|
+
const desc = values.map(v => `[${safeName}="${CSS.escape(v)}"] *`);
|
|
159
|
+
const matchScope = [...self, ...desc].join(',');
|
|
160
|
+
|
|
161
|
+
if (kind === 'hide') {
|
|
162
|
+
// hide-when: hide WHEN inside a matching scope — the inverse selector shape.
|
|
163
|
+
return `${attrSelector}:is(${matchScope}){display:none!important}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let scopeSelectors;
|
|
167
|
+
if (kind === 'show-not') {
|
|
168
|
+
// option-not: active when ancestor has attr but NOT any of the values
|
|
169
|
+
const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
|
|
170
|
+
const scope = `[${safeName}]:not(${notList})`;
|
|
171
|
+
scopeSelectors = `${scope},${scope} *`;
|
|
172
|
+
} else {
|
|
173
|
+
// option / show-when: active when ancestor has ANY of the values
|
|
174
|
+
scopeSelectors = matchScope;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `${attrSelector}:not(:is(${scopeSelectors})){display:none!important}`;
|
|
178
|
+
}).join('');
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Update the style element with current rules
|
|
183
|
+
*/
|
|
184
|
+
update() {
|
|
185
|
+
try {
|
|
186
|
+
const attributes = this.findOptionAttributes();
|
|
187
|
+
const css = this.generateCSS(attributes);
|
|
188
|
+
insertStyles(STYLE_NAME, css, (style) => {
|
|
189
|
+
style.setAttribute('clay', 'no-watch');
|
|
190
|
+
});
|
|
191
|
+
this.log(`Generated ${attributes.length} rules`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error('[OptionVisibility] Error generating rules:', error);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
start() {
|
|
198
|
+
if (this._started) return;
|
|
199
|
+
|
|
200
|
+
if (document.readyState === 'loading') {
|
|
201
|
+
document.addEventListener('DOMContentLoaded', () => this.start(), { once: true });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this._started = true;
|
|
206
|
+
|
|
207
|
+
this.update();
|
|
208
|
+
|
|
209
|
+
// selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
|
|
210
|
+
// Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
|
|
211
|
+
// automatically by the browser - CSS rules re-evaluate when attributes change.
|
|
212
|
+
this._unsubscribe = Mutation.onAnyChange({
|
|
213
|
+
debounce: 200,
|
|
214
|
+
selectorFilter: el => [...el.attributes].some(attr =>
|
|
215
|
+
attr.name.startsWith('option:') || attr.name.startsWith('option-not:') ||
|
|
216
|
+
attr.name.startsWith('show-when:') || attr.name.startsWith('hide-when:')
|
|
217
|
+
),
|
|
218
|
+
omitChangeDetails: true,
|
|
219
|
+
require: 'observed',
|
|
220
|
+
pausable: false
|
|
221
|
+
}, () => this.update());
|
|
222
|
+
|
|
223
|
+
this.log('Started');
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
stop() {
|
|
227
|
+
if (!this._started) return;
|
|
228
|
+
|
|
229
|
+
this._started = false;
|
|
230
|
+
|
|
231
|
+
if (this._unsubscribe) {
|
|
232
|
+
this._unsubscribe();
|
|
233
|
+
this._unsubscribe = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const style = document.querySelector(`style[data-name="${STYLE_NAME}"]`);
|
|
237
|
+
if (style) style.remove();
|
|
238
|
+
|
|
239
|
+
this.log('Stopped');
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// vendor-compat mirror: sap reads window.hyperclay.optionVisibility at runtime.
|
|
244
|
+
window.hyperclay = window.hyperclay || {};
|
|
245
|
+
window.hyperclay.optionVisibility = optionVisibility;
|
|
246
|
+
|
|
247
|
+
export default optionVisibility;
|
|
248
|
+
|
|
249
|
+
export function init() {
|
|
250
|
+
optionVisibility.start();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
init();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Browser-backed demo mode: the page saves into the visitor's localStorage
|
|
2
|
+
// instead of a server, and restores itself on the next load. Setup:
|
|
3
|
+
//
|
|
4
|
+
// <html autosave demo-key="my-page-v1"> <!-- both attributes optional -->
|
|
5
|
+
// <script>window.clayEditMode = true;</script> <!-- before the clay.js tag -->
|
|
6
|
+
// <script src="https://clayjs.com/clay.js?plugins=demo"></script>
|
|
7
|
+
//
|
|
8
|
+
// The fetch shim answers POST /_/save the way a clayjs server would, so the
|
|
9
|
+
// whole real pipeline (savestatus, events, autosave, ⌘S) runs unchanged. The
|
|
10
|
+
// stored body comes from the clay:snapshot-ready clone (pre-strip, the same
|
|
11
|
+
// source live-sync morphs from), not the POSTed payload: the payload has
|
|
12
|
+
// richclay's runtime state stripped, and morphing toward it would disable the
|
|
13
|
+
// live editors. richclay chrome and squire artifacts are dropped from storage
|
|
14
|
+
// because richclay recreates them. The shim answers after a short delay:
|
|
15
|
+
// localStorage is instant, and the saving state only shows past save.js's
|
|
16
|
+
// 500ms debounce, so an instant answer would keep it invisible.
|
|
17
|
+
//
|
|
18
|
+
// The storage key includes <html demo-key> (fallback: the page's pathname).
|
|
19
|
+
// Bump the attribute whenever the page's authored content changes, or
|
|
20
|
+
// returning visitors' old saved bodies morph over the new page.
|
|
21
|
+
|
|
22
|
+
import { morph } from "../vendor/hyper-morph.vendor.js";
|
|
23
|
+
|
|
24
|
+
const KEY = "clay:demo:" +
|
|
25
|
+
(document.documentElement.getAttribute("demo-key") || window.location.pathname);
|
|
26
|
+
const LATENCY_MS = 800;
|
|
27
|
+
const CHROME_SELECTOR = [
|
|
28
|
+
"[save-remove]",
|
|
29
|
+
"[data-richclay-toolbar]",
|
|
30
|
+
"[data-richclay-menu]",
|
|
31
|
+
"[data-richclay-dialog]",
|
|
32
|
+
"[data-richclay-live]",
|
|
33
|
+
"[data-richclay-float]",
|
|
34
|
+
"#squire-selection-start",
|
|
35
|
+
"#squire-selection-end",
|
|
36
|
+
".squire-image-resize-container",
|
|
37
|
+
].join(",");
|
|
38
|
+
|
|
39
|
+
let failNextSave = false;
|
|
40
|
+
let snapshotBody = null;
|
|
41
|
+
|
|
42
|
+
document.addEventListener("clay:snapshot-ready", (event) => {
|
|
43
|
+
snapshotBody = event.detail.documentElement.querySelector("body")?.outerHTML || null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function postedHtml(raw) {
|
|
47
|
+
const text = typeof raw === "string" ? raw : "";
|
|
48
|
+
try {
|
|
49
|
+
const envelope = JSON.parse(text);
|
|
50
|
+
if (envelope && typeof envelope === "object") {
|
|
51
|
+
return envelope.snapshotHtml || envelope.content || text;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const realFetch = window.fetch.bind(window);
|
|
58
|
+
|
|
59
|
+
function isSaveUrl(input) {
|
|
60
|
+
const target = typeof input === "string" ? input : (input && input.url) || "";
|
|
61
|
+
try {
|
|
62
|
+
const path = new URL(target, window.location.href).pathname;
|
|
63
|
+
return path === "/_/save" || path.startsWith("/_/save/");
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
window.fetch = (url, options = {}) => {
|
|
70
|
+
const method = (options.method || (url && url.method) || "GET").toUpperCase();
|
|
71
|
+
if (method !== "POST" || !isSaveUrl(url)) {
|
|
72
|
+
return realFetch(url, options);
|
|
73
|
+
}
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (failNextSave) {
|
|
77
|
+
failNextSave = false;
|
|
78
|
+
resolve(new Response(JSON.stringify({ msg: "Server not responding", msgType: "error" }), {
|
|
79
|
+
status: 503,
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
}));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const html = snapshotBody || postedHtml(options.body);
|
|
85
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
86
|
+
doc.querySelectorAll(CHROME_SELECTOR).forEach((node) => node.remove());
|
|
87
|
+
try {
|
|
88
|
+
localStorage.setItem(KEY, doc.body.outerHTML);
|
|
89
|
+
} catch {
|
|
90
|
+
resolve(new Response(JSON.stringify({ msg: "Couldn't save in this browser (storage is full or blocked)", msgType: "error" }), {
|
|
91
|
+
status: 507,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
}));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve(new Response(JSON.stringify({ msg: "Saved in your browser", msgType: "success" }), {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
}));
|
|
100
|
+
}, LATENCY_MS);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function restore() {
|
|
105
|
+
const saved = localStorage.getItem(KEY);
|
|
106
|
+
if (!saved) return;
|
|
107
|
+
const doc = new DOMParser().parseFromString(saved, "text/html");
|
|
108
|
+
await morph(document.body, doc.body, {
|
|
109
|
+
morphStyle: "outerHTML",
|
|
110
|
+
ignoreActiveValue: true,
|
|
111
|
+
scripts: { handle: true, matchMode: "smart" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ready = restore();
|
|
116
|
+
|
|
117
|
+
export const demo = {
|
|
118
|
+
key: KEY,
|
|
119
|
+
failNext() { failNextSave = true; },
|
|
120
|
+
reset() {
|
|
121
|
+
localStorage.removeItem(KEY);
|
|
122
|
+
window.location.reload();
|
|
123
|
+
},
|
|
124
|
+
};
|
package/src/sync/live-sync.js
CHANGED
|
@@ -39,6 +39,7 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
|
39
39
|
import Mutation from "../lib/mutation.js";
|
|
40
40
|
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
|
+
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
42
43
|
|
|
43
44
|
class LiveSync {
|
|
44
45
|
constructor() {
|
|
@@ -46,6 +47,15 @@ class LiveSync {
|
|
|
46
47
|
this.currentFile = null;
|
|
47
48
|
this.lastHtml = null;
|
|
48
49
|
this.clientId = this.generateClientId();
|
|
50
|
+
|
|
51
|
+
// Per-stream resume id for the htmlclay replay server's wire contract.
|
|
52
|
+
// Regenerated on every start() (see below) so an explicit stop/start is a
|
|
53
|
+
// brand-new stream that never inherits the old baseline. Native
|
|
54
|
+
// EventSource reuses the same URL — and thus this id — on its own
|
|
55
|
+
// automatic reconnect. Deliberately NOT the sender clientId, which is the
|
|
56
|
+
// durable per-tab identity used for echo suppression.
|
|
57
|
+
this.resumeId = null;
|
|
58
|
+
|
|
49
59
|
this.debounceMs = 150;
|
|
50
60
|
this.debounceTimer = null;
|
|
51
61
|
this.isPaused = false;
|
|
@@ -132,6 +142,21 @@ class LiveSync {
|
|
|
132
142
|
return id;
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Generate a fresh per-stream resume id (URL-safe, well under the 128-byte
|
|
147
|
+
* wire limit). Minted on every start(), never persisted: an explicit
|
|
148
|
+
* stop/start must not reuse the previous stream's baseline.
|
|
149
|
+
*/
|
|
150
|
+
generateResumeId() {
|
|
151
|
+
try {
|
|
152
|
+
const bytes = new Uint8Array(16);
|
|
153
|
+
(globalThis.crypto || window.crypto).getRandomValues(bytes);
|
|
154
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
/**
|
|
136
161
|
* Start the LiveSync system
|
|
137
162
|
* Can be called after stop() to restart with a new file
|
|
@@ -152,9 +177,11 @@ class LiveSync {
|
|
|
152
177
|
return;
|
|
153
178
|
}
|
|
154
179
|
|
|
155
|
-
// Reset state for new connection
|
|
180
|
+
// Reset state for new connection. Resetting lastSeenSeq is a new baseline,
|
|
181
|
+
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
156
182
|
this.lastHtml = null;
|
|
157
183
|
this.lastSeenSeq = 0;
|
|
184
|
+
this.resumeId = this.generateResumeId();
|
|
158
185
|
|
|
159
186
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
160
187
|
this.connect();
|
|
@@ -360,7 +387,7 @@ class LiveSync {
|
|
|
360
387
|
if (this.isDestroyed) return;
|
|
361
388
|
|
|
362
389
|
const pageUrl = encodeURIComponent(window.location.href);
|
|
363
|
-
const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
|
|
390
|
+
const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}&resume-id=${this.resumeId}`;
|
|
364
391
|
this.sse = new EventSource(url);
|
|
365
392
|
|
|
366
393
|
this.sse.onopen = () => {
|
|
@@ -371,6 +398,26 @@ class LiveSync {
|
|
|
371
398
|
this.sse.onmessage = (event) => {
|
|
372
399
|
const data = JSON.parse(event.data);
|
|
373
400
|
|
|
401
|
+
// Staleness check + watermark advance run FIRST — before the notification
|
|
402
|
+
// and error branches — against the high-water mark of seqs we've seen
|
|
403
|
+
// (own echoes count too, see below). Notification frames now carry a seq,
|
|
404
|
+
// so a conservative-cursor replay after reconnect must not duplicate a
|
|
405
|
+
// toast or let a later stale data frame apply an older snapshot. `seq` is
|
|
406
|
+
// optional for back-compat with older servers that don't stamp it.
|
|
407
|
+
const { seq } = data;
|
|
408
|
+
if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
|
|
409
|
+
this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Advance the watermark before the notification/sender branches so that
|
|
414
|
+
// our own save echoes count toward "seen". Without this, a later
|
|
415
|
+
// buffered/replayed message with a smaller seq could rewind us past our
|
|
416
|
+
// local edit.
|
|
417
|
+
if (typeof seq === 'number') {
|
|
418
|
+
this.lastSeenSeq = seq;
|
|
419
|
+
}
|
|
420
|
+
|
|
374
421
|
// Handle notifications (show toast, don't morph)
|
|
375
422
|
if (data.type === "notification") {
|
|
376
423
|
this.handleNotification(data);
|
|
@@ -384,22 +431,7 @@ class LiveSync {
|
|
|
384
431
|
return;
|
|
385
432
|
}
|
|
386
433
|
|
|
387
|
-
const { html, sender,
|
|
388
|
-
|
|
389
|
-
// Staleness check runs FIRST — compared against the high-water mark of
|
|
390
|
-
// seqs we've seen (own echoes count too, see below). `seq` is optional
|
|
391
|
-
// for back-compat with older server builds that don't stamp it.
|
|
392
|
-
if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
|
|
393
|
-
this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Advance the watermark before the sender filter so that our own save
|
|
398
|
-
// echoes count toward "seen". Without this, a later buffered/replayed
|
|
399
|
-
// peer message with a smaller seq could rewind us past our local edit.
|
|
400
|
-
if (typeof seq === 'number') {
|
|
401
|
-
this.lastSeenSeq = seq;
|
|
402
|
-
}
|
|
434
|
+
const { html, sender, identityMap } = data;
|
|
403
435
|
|
|
404
436
|
// Ignore own changes — already reflected in the DOM, nothing to morph
|
|
405
437
|
if (sender === this.clientId) {
|
|
@@ -639,7 +671,17 @@ class LiveSync {
|
|
|
639
671
|
morphStyle: 'outerHTML',
|
|
640
672
|
ignoreActiveValue: true,
|
|
641
673
|
head: { style: 'merge' },
|
|
642
|
-
|
|
674
|
+
// mergeBase: mergeable script tags ([merge] + rules tags) three-way
|
|
675
|
+
// merge against the last synced state instead of being clobbered by
|
|
676
|
+
// the incoming save; lastHtml is exactly that base (set after every
|
|
677
|
+
// own save and every applied morph). Null on the first update →
|
|
678
|
+
// two-way merge, which still keeps local-only keys.
|
|
679
|
+
scripts: {
|
|
680
|
+
handle: true,
|
|
681
|
+
matchMode: 'smart',
|
|
682
|
+
mergeBase: this.lastHtml,
|
|
683
|
+
mergeTags: mergeTagRecognizers
|
|
684
|
+
},
|
|
643
685
|
key,
|
|
644
686
|
callbacks: { afterNodeMorphed }
|
|
645
687
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-tags.js — extra mergeable script-tag recognizers for hyper-morph.
|
|
3
|
+
*
|
|
4
|
+
* hyper-morph's built-in recognizer handles `merge="<name>"` tags with
|
|
5
|
+
* relaxed JSON. This adds the Hyperclay rules-tag family (hypercms and
|
|
6
|
+
* hyper-html-api): `script[data-rules-name][data-rules-version]`, whose
|
|
7
|
+
* bodies use the rules dialect (unquoted selector barewords become strings).
|
|
8
|
+
* Identity is the version plus the sorted name-token list, so token order
|
|
9
|
+
* never matters and differing schema versions never pair. Parsing goes
|
|
10
|
+
* through hyper-morph's parseRulesRelaxed, the canonical port of the
|
|
11
|
+
* engine's parseRelaxed, so the tags merge exactly as they load.
|
|
12
|
+
*
|
|
13
|
+
* Pass as `scripts: { mergeTags: mergeTagRecognizers }` on morph configs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
17
|
+
|
|
18
|
+
export const mergeTagRecognizers = [
|
|
19
|
+
{
|
|
20
|
+
match: (el) => el.hasAttribute("data-rules-name"),
|
|
21
|
+
identity: (el) => {
|
|
22
|
+
const names = (el.getAttribute("data-rules-name") || "")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.sort()
|
|
26
|
+
.join(" ");
|
|
27
|
+
if (!names) return null;
|
|
28
|
+
return (
|
|
29
|
+
"rules:" + (el.getAttribute("data-rules-version") || "") + ":" + names
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
parse: HyperMorph.parseRulesRelaxed,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default mergeTagRecognizers;
|