@panphora/clayjs 1.2.0 → 1.4.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.
Files changed (42) hide show
  1. package/README.md +7 -3
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +22539 -14260
  4. package/entries/clay-data.js +1 -1
  5. package/entries/sap.js +1 -1
  6. package/package.json +7 -2
  7. package/packed-contract.json +17 -0
  8. package/src/attrs/save-freeze.js +10 -18
  9. package/src/core/admin-contenteditable.js +8 -6
  10. package/src/core/admin-inputs.js +23 -13
  11. package/src/core/admin-onclick.js +5 -0
  12. package/src/core/is-edit-mode.js +7 -2
  13. package/src/core/persist.js +5 -10
  14. package/src/core/save-core.js +35 -0
  15. package/src/core/save.js +13 -1
  16. package/src/core/snapshot.js +186 -14
  17. package/src/core/source-map.js +1025 -0
  18. package/src/core/unsaved-warning.js +3 -0
  19. package/src/dom/dom-helpers.js +5 -1
  20. package/src/lib/content-dom.js +108 -0
  21. package/src/lib/mutation.js +26 -3
  22. package/src/lib/region-capabilities.js +69 -0
  23. package/src/lib/region-policy.js +18 -13
  24. package/src/loader-logic.js +27 -5
  25. package/src/loader.js +6 -0
  26. package/src/plugins/ai-edit.js +625 -0
  27. package/src/plugins/demo.js +3 -0
  28. package/src/plugins/sortable.js +6 -1
  29. package/src/plugins/source.js +410 -0
  30. package/src/plugins/wire.js +248 -47
  31. package/src/sync/live-sync.js +106 -42
  32. package/src/sync/presence.js +303 -0
  33. package/src/sync/section-notice.js +230 -0
  34. package/src/sync/splice-merge.js +7 -10
  35. package/src/sync/stream.js +190 -0
  36. package/src/vendor/control-serialize.vendor.js +10 -6
  37. package/src/vendor/hyper-morph.vendor.js +2 -2
  38. package/src/vendor/hyper-undo.vendor.js +1 -1
  39. package/src/vendor/hypercms.vendor.js +438 -45
  40. package/src/vendor/parse5.vendor.js +3 -0
  41. package/src/vendor/quickcrop.vendor.js +1 -1
  42. package/src/vendor/richclay.vendor.js +22 -15
@@ -0,0 +1,230 @@
1
+ /**
2
+ * section-notice.js — "<name> changed this section".
3
+ *
4
+ * One dismissible line, shown when a live-sync frame from somebody else
5
+ * actually changed the editable region this reader was working in.
6
+ *
7
+ * HOW "THIS SECTION CHANGED" IS DECIDED, AND WHY BOTH OBVIOUS READINGS ARE WRONG.
8
+ *
9
+ * Not "an ancestor was morphed". Every frame morphs `document.documentElement`,
10
+ * so `body` is an ancestor of every region there is and that test reports every
11
+ * unrelated edit anybody makes anywhere on the page.
12
+ *
13
+ * Not "an ancestor was replaced". hyper-morph matches and mutates IN PLACE
14
+ * (morphNode / morphAttributes), so nothing is replaced when a peer retypes a
15
+ * sentence — which is the common case this notice exists for.
16
+ *
17
+ * So the region's own content is compared across the morph. The baseline is
18
+ * recorded when the reader focuses the region and refreshed while they type, so
19
+ * their own keystrokes are never read as somebody else's edit, and the
20
+ * comparison runs on `clay:sync-applied`, which fires after the awaited morph.
21
+ * It is re-recorded on EVERY applied frame, named or not: a change nobody is
22
+ * named for would otherwise leave the baseline behind the screen, and the next
23
+ * named frame — which may have touched nothing here — would inherit it and put
24
+ * the wrong person's name on somebody else's edit.
25
+ *
26
+ * The one thing that gets past it: a page script that rewrites the region
27
+ * without an `input` event. That drift is charged to whichever named frame lands
28
+ * next. Typing, pasting and every other user edit of a control or a
29
+ * contenteditable subtree do fire one, which is the case this is for.
30
+ *
31
+ * The region is kept AFTER focus leaves, deliberately. hyper-morph's
32
+ * `ignoreActiveValue` skips both the value sync and the child morph for
33
+ * `document.activeElement`, so nothing can change under the caret while it is
34
+ * still there; the frame worth reporting is the one that lands on the paragraph
35
+ * the reader just left.
36
+ *
37
+ * DISMISS ONLY. An undo here would mean recovering displaced local work, which
38
+ * nothing in this library builds. Unsaved local work is protected before the
39
+ * morph by protectPeerDoc, and a refused save is still the conflict bar's job.
40
+ *
41
+ * Silent with no `by` on the frame, which is the whole gate: the server stamps
42
+ * an author on live-lane frames alone, so a saved-lane reader — who must never
43
+ * be told who wrote the public page they are reading — is silent by
44
+ * construction rather than by a second rule that could disagree.
45
+ *
46
+ * Every root carries all three runtime tokens, for the reason presence.js's
47
+ * header sets out: captureForSaveAndComparison() hands its clone to peers on
48
+ * `clay:snapshot-ready` and strips the save-only regions only AFTER that, so
49
+ * `no-save` alone keeps an author's name out of the file while broadcasting it
50
+ * to every peer on the document.
51
+ *
52
+ * no-save not written to the saved file
53
+ * no-watch invisible to the mutation system, so a notice is not an edit
54
+ * no-snapshot absent from every snapshot, including the one peers receive
55
+ *
56
+ * The name reaches the DOM in one text node inside that subtree and is held
57
+ * nowhere else — no attribute, no data store, nothing a serializer walks.
58
+ */
59
+
60
+ import { make, set } from '../lib/hostile-css.js';
61
+
62
+ // Runtime-only chrome, in one string so no root can carry two of the three.
63
+ const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
64
+
65
+ const BG = 'var(--clay-section-bg,#222)';
66
+ const INK = 'var(--clay-section-ink,#fff)';
67
+ const EDGE = 'var(--clay-section-edge,rgba(255,255,255,.28))';
68
+ const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
69
+
70
+ // A control's work is its live value, which its markup does not carry. An
71
+ // editable subtree's is its markup, and `outerHTML` covers the region's own
72
+ // attributes too, so an in-place attribute change on it counts as a change.
73
+ const CONTROLS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
74
+
75
+ // The nearest element that DECLARES the attribute, which is the host of the
76
+ // editable subtree. `isContentEditable` is inherited, so it answers true for
77
+ // every descendant text node's parent and names no region at all.
78
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
79
+
80
+ /** Every element this module creates, marked the same way. */
81
+ function runtimeRoot(el) {
82
+ el.setAttribute('clay', RUNTIME_ONLY);
83
+ return el;
84
+ }
85
+
86
+ function regionOf(target) {
87
+ if (!target || target.nodeType !== 1 || typeof target.closest !== 'function') return null;
88
+ if (CONTROLS.has(target.tagName)) return target;
89
+ return target.closest(EDITABLE);
90
+ }
91
+
92
+ // Null for a region the frame removed outright, which differs from every string
93
+ // a live region can produce and so reads as the change it is.
94
+ function contentOf(el) {
95
+ if (!el || !el.isConnected) return null;
96
+ return CONTROLS.has(el.tagName) ? String(el.value) : el.outerHTML;
97
+ }
98
+
99
+ // The stamp arrived over a wire. The server promises a name on every live-lane
100
+ // frame, and this still checks, because a frame that cannot be understood must
101
+ // draw nothing rather than draw "undefined changed this section".
102
+ function nameOf(by) {
103
+ if (!by || typeof by !== 'object') return null;
104
+ const name = typeof by.name === 'string' ? by.name.trim() : '';
105
+ return name || null;
106
+ }
107
+
108
+ class SectionNotice {
109
+ constructor() {
110
+ this.region = null;
111
+ this.content = null;
112
+ this.root = null;
113
+ this.line = null;
114
+ this._wired = false;
115
+ this._onFocusIn = (event) => this.remember(event.target);
116
+ this._onEdit = (event) => this.refresh(event.target);
117
+ this._onApplied = (event) => this.applied(event && event.detail);
118
+ }
119
+
120
+ /** Idempotent, so importing this module twice cannot double-report a frame. */
121
+ init() {
122
+ if (this._wired || typeof document === 'undefined') return;
123
+ this._wired = true;
124
+ document.addEventListener('focusin', this._onFocusIn);
125
+ // `input` alone: a <select> fires it before its `change`, and every other
126
+ // user edit of a control or an editable subtree fires it too.
127
+ document.addEventListener('input', this._onEdit);
128
+ document.addEventListener('clay:sync-applied', this._onApplied);
129
+ }
130
+
131
+ destroy() {
132
+ if (!this._wired) return;
133
+ this._wired = false;
134
+ document.removeEventListener('focusin', this._onFocusIn);
135
+ document.removeEventListener('input', this._onEdit);
136
+ document.removeEventListener('clay:sync-applied', this._onApplied);
137
+ this.region = null;
138
+ this.content = null;
139
+ if (this.root) this.root.remove();
140
+ this.root = null;
141
+ this.line = null;
142
+ }
143
+
144
+ /** The section the reader is working in. Focus on anything else leaves it alone. */
145
+ remember(target) {
146
+ const region = regionOf(target);
147
+ if (!region) return;
148
+ this.region = region;
149
+ this.content = contentOf(region);
150
+ }
151
+
152
+ /** Their own typing is not somebody else's edit. */
153
+ refresh(target) {
154
+ const region = regionOf(target);
155
+ if (!region || region !== this.region) return;
156
+ this.content = contentOf(region);
157
+ }
158
+
159
+ applied(detail) {
160
+ if (!this.region) return;
161
+ const now = contentOf(this.region);
162
+ const changed = now !== this.content;
163
+ this.content = now;
164
+ if (!changed) return;
165
+ const name = nameOf(detail && detail.by);
166
+ if (!name) return;
167
+ this.show(name);
168
+ }
169
+
170
+ show(name) {
171
+ if (!this.build()) return;
172
+ this.line.textContent = `${name} changed this section`;
173
+ set(this.root, 'display', 'flex');
174
+ }
175
+
176
+ hide() {
177
+ if (!this.root) return;
178
+ this.line.textContent = '';
179
+ set(this.root, 'display', 'none');
180
+ }
181
+
182
+ build() {
183
+ if (this.root && this.root.isConnected) return true;
184
+ if (typeof document === 'undefined' || !document.body) return false;
185
+
186
+ this.root = runtimeRoot(make('div', [
187
+ 'position:fixed',
188
+ 'left:calc(12px + env(safe-area-inset-left,0px))',
189
+ // Clear of the conflict bar, which is bottom-anchored and can span almost
190
+ // the whole width of a phone. That bar is the more urgent of the two, so
191
+ // this one moves rather than covering it.
192
+ 'bottom:calc(72px + env(safe-area-inset-bottom,0px))',
193
+ 'z-index:2147483000',
194
+ 'display:none', 'align-items:center', 'gap:10px',
195
+ 'box-sizing:border-box', 'max-width:calc(100vw - 24px)',
196
+ 'padding:9px 12px', 'border-radius:10px',
197
+ `background-color:${BG}`, 'background-image:none', `color:${INK}`,
198
+ 'border-width:1px', 'border-style:solid', `border-color:${EDGE}`,
199
+ 'box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)',
200
+ `font:${FONT}`, 'text-align:left',
201
+ ]));
202
+ this.root.setAttribute('data-clay-section-notice', '');
203
+ this.root.setAttribute('role', 'status');
204
+
205
+ this.line = runtimeRoot(make('span', ['flex:1 1 auto']));
206
+ this.line.setAttribute('data-clay-section-notice-line', '');
207
+
208
+ // all:initial first, because a page restyling every button is the normal
209
+ // case rather than the adversarial one; everything it needs is restated.
210
+ const dismiss = runtimeRoot(make('button', [
211
+ 'all:initial', 'box-sizing:border-box', 'cursor:pointer', `font:${FONT}`,
212
+ 'font-weight:500', 'border-radius:6px', 'padding:4px 8px',
213
+ 'white-space:nowrap', 'flex:none', `color:${INK}`, 'opacity:.72',
214
+ ], 'Dismiss'));
215
+ dismiss.type = 'button';
216
+ dismiss.setAttribute('data-clay-section-notice-dismiss', '');
217
+ dismiss.addEventListener('click', () => this.hide());
218
+
219
+ this.root.append(this.line, dismiss);
220
+ document.body.appendChild(this.root);
221
+ return true;
222
+ }
223
+ }
224
+
225
+ const sectionNotice = new SectionNotice();
226
+
227
+ if (typeof document !== 'undefined') sectionNotice.init();
228
+
229
+ export { sectionNotice, SectionNotice };
230
+ export default sectionNotice;
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { findChangedRoots, spliceProtected } from '../vendor/hyper-morph.vendor.js';
35
- import { captureSnapshot, captureForMerge } from '../core/snapshot.js';
35
+ import { captureSnapshot, captureForMerge, originalSnapshotNode } from '../core/snapshot.js';
36
36
  // save.js is edit-only in the loader waves but safe to reach from here: its
37
37
  // module body guards every init on isEditMode, and the disk lane that needs
38
38
  // this state only ever runs in edit-mode tabs.
@@ -109,17 +109,14 @@ function walkPairs(a, b, cb) {
109
109
  * whose child counts diverge (extension noise beside the clone's strip) is
110
110
  * skipped — those elements fall back to data-id / id matching.
111
111
  */
112
- function fillCloneIds(liveEl, cloneEl, liveWeakMap, idOf) {
113
- const id = liveWeakMap.get(liveEl);
112
+ function fillCloneIds(liveEl, cloneEl, liveWeakMap, idOf, root = true) {
113
+ const live = originalSnapshotNode(cloneEl) || (root ? liveEl : null);
114
+ const id = liveWeakMap.get(live);
114
115
  if (id) idOf.set(cloneEl, id);
115
- const liveKids = [];
116
- for (const c of liveEl.children) {
117
- if (!isSnapshotRemoved(c)) liveKids.push(c);
118
- }
119
116
  const cloneKids = cloneEl.children;
120
- if (liveKids.length !== cloneKids.length) return;
121
- for (let i = 0; i < liveKids.length; i++) {
122
- fillCloneIds(liveKids[i], cloneKids[i], liveWeakMap, idOf);
117
+ for (let i = 0; i < cloneKids.length; i++) {
118
+ const childLive = originalSnapshotNode(cloneKids[i]);
119
+ fillCloneIds(childLive, cloneKids[i], liveWeakMap, idOf, false);
123
120
  }
124
121
  }
125
122
 
@@ -0,0 +1,190 @@
1
+ const START_TIMEOUT = 5000;
2
+ const HIDDEN_DELAY = 5000;
3
+ const PING_INTERVAL = 15000;
4
+
5
+ export class SyncStream extends EventTarget {
6
+ constructor(url, { shared = false, documentURL, lane } = {}) {
7
+ super();
8
+ this.url = url;
9
+ this.readyState = 0;
10
+ this.onopen = null;
11
+ this.onmessage = null;
12
+ this.onerror = null;
13
+ this._shared = shared && typeof SharedWorker === 'function';
14
+ this._documentURL = documentURL;
15
+ this._lane = lane;
16
+ this._since = 0;
17
+ this._closed = false;
18
+ this._suspended = false;
19
+ this._repair = false;
20
+ this._worker = null;
21
+ this._source = null;
22
+ this._visibility = () => {
23
+ clearTimeout(this._hiddenTimer);
24
+ if (document.hidden) {
25
+ this._worker?.port.postMessage({ v: 1, type: 'hidden' });
26
+ this._hiddenTimer = setTimeout(() => this._suspend(), HIDDEN_DELAY);
27
+ } else {
28
+ if (this._suspended) this._resume();
29
+ else if (this._worker) {
30
+ this._worker.port.postMessage({ v: 1, type: 'visible' });
31
+ }
32
+ }
33
+ };
34
+ this._pagehide = () => this._suspend();
35
+ this._pageshow = () => { if (!document.hidden) this._resume(); };
36
+ document.addEventListener('visibilitychange', this._visibility);
37
+ window.addEventListener('pagehide', this._pagehide);
38
+ window.addEventListener('pageshow', this._pageshow);
39
+ this._open();
40
+ if (document.hidden) this._visibility();
41
+ }
42
+
43
+ _emit(type, data) {
44
+ if (this._closed) return;
45
+ const event = data === undefined ? new Event(type) : new MessageEvent(type, { data });
46
+ this.dispatchEvent(event);
47
+ this[`on${type}`]?.(event);
48
+ }
49
+
50
+ _open() {
51
+ if (this._closed || this._suspended) return;
52
+ this.readyState = 0;
53
+ if (!this._shared) return this._openDirect();
54
+ try {
55
+ const worker = new SharedWorker(new URL('/_/sync/worker.js', window.location.origin).href, 'clay-sync');
56
+ this._worker = worker;
57
+ let lastReply = Date.now();
58
+ worker.onerror = () => {
59
+ if (this._worker !== worker) return;
60
+ this._shared = false;
61
+ this._repair = true;
62
+ this._reopen();
63
+ };
64
+ this._startTimer = setTimeout(worker.onerror, START_TIMEOUT);
65
+ worker.port.onmessage = ({ data }) => {
66
+ if (this._worker !== worker || data?.v !== 1) return;
67
+ lastReply = Date.now();
68
+ if (['status', 'pong', 'cursor', 'frame', 'gone'].includes(data.type)) clearTimeout(this._startTimer);
69
+ if (data.type === 'status') {
70
+ if (data.state === 'open') {
71
+ this.readyState = 1;
72
+ this._emit('open');
73
+ } else if (data.state === 'connecting') {
74
+ this.readyState = 0;
75
+ this._emit('error');
76
+ }
77
+ } else if (data.type === 'cursor') {
78
+ clearTimeout(this._startTimer);
79
+ if (Number.isSafeInteger(data.seq)) this._since = Math.max(this._since, data.seq);
80
+ this._emit('cursor', JSON.stringify({ ...data, resync: data.resync === true || this._repair }));
81
+ this._repair = false;
82
+ } else if (data.type === 'frame' && typeof data.data === 'string') {
83
+ this._remember(data.data);
84
+ this._emit('message', data.data);
85
+ } else if (data.type === 'gone') {
86
+ this.readyState = 2;
87
+ this._emit('error');
88
+ this.close();
89
+ }
90
+ };
91
+ worker.port.start();
92
+ worker.port.postMessage({ v: 1, type: document.hidden ? 'hidden' : 'visible' });
93
+ worker.port.postMessage({ v: 1, type: 'subscribe', document: this._documentURL, lane: this._lane, since: this._since });
94
+ this._pingTimer = setInterval(() => {
95
+ if (this._worker !== worker) return;
96
+ if (Date.now() - lastReply > PING_INTERVAL * 3) {
97
+ this._repair = true;
98
+ this._reopen();
99
+ } else worker.port.postMessage({ v: 1, type: 'ping' });
100
+ }, PING_INTERVAL);
101
+ } catch {
102
+ this._shared = false;
103
+ this._reopen();
104
+ }
105
+ }
106
+
107
+ _openDirect() {
108
+ const source = new EventSource(this.url);
109
+ this._source = source;
110
+ source.onopen = () => {
111
+ if (this._source !== source) return;
112
+ this.readyState = 1;
113
+ this._emit('open');
114
+ if (this._repair) {
115
+ this._repair = false;
116
+ this._emit('cursor', JSON.stringify({ resync: true }));
117
+ }
118
+ };
119
+ source.onerror = () => {
120
+ if (this._source !== source) return;
121
+ this.readyState = source.readyState;
122
+ this._emit('error');
123
+ };
124
+ source.onmessage = event => {
125
+ if (this._source !== source) return;
126
+ this._remember(event.data);
127
+ this._emit('message', event.data);
128
+ };
129
+ for (const type of ['cursor', 'presence']) {
130
+ source.addEventListener(type, event => {
131
+ if (this._source === source) this._emit(type, event.data);
132
+ });
133
+ }
134
+ }
135
+
136
+ _remember(data) {
137
+ try {
138
+ const { seq } = JSON.parse(data);
139
+ if (Number.isSafeInteger(seq)) this._since = Math.max(this._since, seq);
140
+ } catch {}
141
+ }
142
+
143
+ _disconnect() {
144
+ clearTimeout(this._startTimer);
145
+ clearInterval(this._pingTimer);
146
+ if (this._worker) {
147
+ const worker = this._worker;
148
+ this._worker = null;
149
+ worker.onerror = null;
150
+ worker.port.onmessage = null;
151
+ try { worker.port.postMessage({ v: 1, type: 'unsubscribe' }); } catch {}
152
+ worker.port.close();
153
+ }
154
+ if (this._source) {
155
+ this._source.close();
156
+ this._source = null;
157
+ }
158
+ }
159
+
160
+ _reopen() {
161
+ this._disconnect();
162
+ this._open();
163
+ }
164
+
165
+ _suspend() {
166
+ clearTimeout(this._hiddenTimer);
167
+ if (this._closed || this._suspended) return;
168
+ this._suspended = true;
169
+ this._repair = true;
170
+ this.readyState = 0;
171
+ this._disconnect();
172
+ }
173
+
174
+ _resume() {
175
+ clearTimeout(this._hiddenTimer);
176
+ if (this._closed || !this._suspended) return;
177
+ this._suspended = false;
178
+ this._open();
179
+ }
180
+
181
+ close() {
182
+ this._closed = true;
183
+ this.readyState = 2;
184
+ clearTimeout(this._hiddenTimer);
185
+ this._disconnect();
186
+ document.removeEventListener('visibilitychange', this._visibility);
187
+ window.removeEventListener('pagehide', this._pagehide);
188
+ window.removeEventListener('pageshow', this._pageshow);
189
+ }
190
+ }
@@ -48,20 +48,24 @@ export function serializeControlToAttributes(el) {
48
48
  }
49
49
  }
50
50
 
51
+ // Writes only what changes what a reload would show. A control whose attributes
52
+ // already reload as its live state is left exactly as the author wrote it, because a
53
+ // source-preserving save copies the author's bytes for every node the save did not
54
+ // change, and a rewrite here is a change.
51
55
  export function finalizeControlForSave(target, source = target) {
52
56
  const tag = target.tagName;
53
57
  if (tag === "INPUT") {
54
58
  const type = inputType(target);
55
59
  if (type === "checkbox" || type === "radio") {
56
- if (source.checked) target.setAttribute("checked", "");
57
- else target.removeAttribute("checked");
58
- } else {
60
+ if (!source.checked) target.removeAttribute("checked");
61
+ else if (!target.hasAttribute("checked")) target.setAttribute("checked", "");
62
+ } else if (source.value !== (target.getAttribute("value") ?? "")) {
59
63
  target.setAttribute("value", source.value);
60
64
  }
61
65
  return;
62
66
  }
63
67
  if (tag === "TEXTAREA") {
64
- target.textContent = source.value;
68
+ if (target.textContent !== source.value) target.textContent = source.value;
65
69
  target.removeAttribute("data-value");
66
70
  return;
67
71
  }
@@ -69,8 +73,8 @@ export function finalizeControlForSave(target, source = target) {
69
73
  const tOpts = target.options;
70
74
  const sOpts = source.options;
71
75
  for (let i = 0; i < tOpts.length; i++) {
72
- if (sOpts[i] && sOpts[i].selected) tOpts[i].setAttribute("selected", "");
73
- else tOpts[i].removeAttribute("selected");
76
+ if (!(sOpts[i] && sOpts[i].selected)) tOpts[i].removeAttribute("selected");
77
+ else if (!tOpts[i].hasAttribute("selected")) tOpts[i].setAttribute("selected", "");
74
78
  }
75
79
  }
76
80
  }