@panphora/clayjs 1.10.0 → 1.11.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.
@@ -1,254 +0,0 @@
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. The three-way merge
32
- * keeps the reader's own typing in the focused region and restores the caret,
33
- * so the frame worth reporting is the one that lands on the paragraph the
34
- * reader just left. The line is drawn beside that region, so the reader sees
35
- * which section it means.
36
- *
37
- * DISMISS ONLY. An undo here would mean recovering displaced local work, which
38
- * nothing in this library builds. Unsaved local work survives the three-way
39
- * merge, 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 { set } from '../lib/hostile-css.js';
61
- import { bevelButton, bevelSurface, bevelText, pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
62
-
63
- // A control's work is its live value, which its markup does not carry. An
64
- // editable subtree's is its markup, and `outerHTML` covers the region's own
65
- // attributes too, so an in-place attribute change on it counts as a change.
66
- const CONTROLS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
67
-
68
- // The nearest element that DECLARES the attribute, which is the host of the
69
- // editable subtree. `isContentEditable` is inherited, so it answers true for
70
- // every descendant text node's parent and names no region at all.
71
- const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
72
-
73
- /** Every element this module creates, marked the same way. */
74
- function runtimeRoot(el) {
75
- el.setAttribute('clay', RUNTIME_ONLY);
76
- return el;
77
- }
78
-
79
- function regionOf(target) {
80
- if (!target || target.nodeType !== 1 || typeof target.closest !== 'function') return null;
81
- if (CONTROLS.has(target.tagName)) return target;
82
- return target.closest(EDITABLE);
83
- }
84
-
85
- // Null for a region the frame removed outright, which differs from every string
86
- // a live region can produce and so reads as the change it is.
87
- function contentOf(el) {
88
- if (!el || !el.isConnected) return null;
89
- return CONTROLS.has(el.tagName) ? String(el.value) : el.outerHTML;
90
- }
91
-
92
- // The stamp arrived over a wire. The server promises a name on every live-lane
93
- // frame, and this still checks, because a frame that cannot be understood must
94
- // draw nothing rather than draw "undefined changed this section".
95
- function nameOf(by) {
96
- if (!by || typeof by !== 'object') return null;
97
- const name = typeof by.name === 'string' ? by.name.trim() : '';
98
- return name || null;
99
- }
100
-
101
- class SectionNotice {
102
- constructor() {
103
- this.region = null;
104
- this.content = null;
105
- this.root = null;
106
- this.line = null;
107
- this._wired = false;
108
- this._onFocusIn = (event) => this.remember(event.target);
109
- this._onEdit = (event) => this.refresh(event.target);
110
- this._onApplied = (event) => this.applied(event && event.detail);
111
- this._onMove = () => this.place();
112
- }
113
-
114
- /** Idempotent, so importing this module twice cannot double-report a frame. */
115
- init() {
116
- if (this._wired || typeof document === 'undefined') return;
117
- this._wired = true;
118
- document.addEventListener('focusin', this._onFocusIn);
119
- // `input` alone: a <select> fires it before its `change`, and every other
120
- // user edit of a control or an editable subtree fires it too.
121
- document.addEventListener('input', this._onEdit);
122
- document.addEventListener('clay:sync-applied', this._onApplied);
123
- }
124
-
125
- destroy() {
126
- if (!this._wired) return;
127
- this._wired = false;
128
- this.follow(false);
129
- document.removeEventListener('focusin', this._onFocusIn);
130
- document.removeEventListener('input', this._onEdit);
131
- document.removeEventListener('clay:sync-applied', this._onApplied);
132
- this.region = null;
133
- this.content = null;
134
- if (this.root) this.root.remove();
135
- this.root = null;
136
- this.line = null;
137
- }
138
-
139
- /** The section the reader is working in. Focus on anything else leaves it alone. */
140
- remember(target) {
141
- const region = regionOf(target);
142
- if (!region) return;
143
- this.region = region;
144
- this.content = contentOf(region);
145
- }
146
-
147
- /** Their own typing is not somebody else's edit. */
148
- refresh(target) {
149
- const region = regionOf(target);
150
- if (!region || region !== this.region) return;
151
- this.content = contentOf(region);
152
- }
153
-
154
- applied(detail) {
155
- if (!this.region) return;
156
- const now = contentOf(this.region);
157
- const changed = now !== this.content;
158
- this.content = now;
159
- if (!changed) return;
160
- const name = nameOf(detail && detail.by);
161
- if (!name) return;
162
- this.show(name);
163
- }
164
-
165
- show(name) {
166
- if (!this.build()) return;
167
- // The conflict notice sits in the same corner and matters more.
168
- const conflict = document.querySelector('[data-clay-conflict]');
169
- if (conflict && conflict.style.display !== 'none') return;
170
- this.line.textContent = `${name} changed this section`;
171
- set(this.root, 'color-scheme', pageScheme());
172
- set(this.root, 'display', 'flex');
173
- this.place();
174
- this.follow(true);
175
- }
176
-
177
- hide() {
178
- if (!this.root) return;
179
- this.line.textContent = '';
180
- set(this.root, 'display', 'none');
181
- this.follow(false);
182
- }
183
-
184
- // Beside the section it names: just above its top edge, or just below it when there
185
- // is no room above. A section scrolled out of view, removed by the frame, or never
186
- // focused leaves the line in its corner, clear of the conflict bar.
187
- place() {
188
- if (!this.root) return;
189
- // The conflict notice hides the line directly; stop following until the next show().
190
- if (this.root.style.display === 'none') { this.follow(false); return; }
191
- const r = this.region && this.region.isConnected ? this.region.getBoundingClientRect() : null;
192
- const vw = document.documentElement.clientWidth || window.innerWidth;
193
- const vh = document.documentElement.clientHeight || window.innerHeight;
194
- if (!r || !(r.width || r.height) || r.bottom <= 0 || r.top >= vh) {
195
- set(this.root, 'top', 'auto');
196
- set(this.root, 'left', 'calc(12px + env(safe-area-inset-left,0px))');
197
- set(this.root, 'bottom', 'calc(72px + env(safe-area-inset-bottom,0px))');
198
- return;
199
- }
200
- const h = this.root.offsetHeight || 40;
201
- const w = this.root.offsetWidth || 280;
202
- const gap = 6;
203
- const top = r.top - h - gap >= 8 ? r.top - h - gap : Math.min(r.bottom + gap, vh - h - 8);
204
- const left = Math.max(12, Math.min(r.left, vw - w - 12));
205
- set(this.root, 'bottom', 'auto');
206
- set(this.root, 'top', `${Math.round(top)}px`);
207
- set(this.root, 'left', `${Math.round(left)}px`);
208
- }
209
-
210
- follow(on) {
211
- if (typeof window === 'undefined' || !!on === !!this._following) return;
212
- this._following = !!on;
213
- const method = on ? 'addEventListener' : 'removeEventListener';
214
- window[method]('scroll', this._onMove, { capture: true, passive: true });
215
- window[method]('resize', this._onMove, { passive: true });
216
- }
217
-
218
- build() {
219
- if (this.root && this.root.isConnected) return true;
220
- if (typeof document === 'undefined' || !document.body) return false;
221
-
222
- this.root = runtimeRoot(bevelSurface('div', [
223
- 'position:fixed',
224
- 'left:calc(12px + env(safe-area-inset-left,0px))',
225
- // Clear of the conflict bar, which is bottom-anchored and can span almost
226
- // the whole width of a phone. That bar is the more urgent of the two, so
227
- // this one moves rather than covering it.
228
- 'bottom:calc(72px + env(safe-area-inset-bottom,0px))',
229
- 'z-index:2147483000',
230
- 'display:none', 'align-items:center', 'gap:10px 12px',
231
- 'max-width:calc(100vw - 24px)', 'padding:8px 8px 8px 14px', 'text-align:left',
232
- `color-scheme:${pageScheme()}`,
233
- ]));
234
- this.root.setAttribute('data-clay-section-notice', '');
235
- this.root.setAttribute('role', 'status');
236
-
237
- this.line = runtimeRoot(bevelText('span', ['flex:1 1 auto']));
238
- this.line.setAttribute('data-clay-section-notice-line', '');
239
-
240
- const dismiss = bevelButton('Dismiss', { small: true, variant: 'quiet', onClick: () => this.hide() });
241
- dismiss.setAttribute('data-clay-section-notice-dismiss', '');
242
-
243
- this.root.append(this.line, dismiss);
244
- document.body.appendChild(this.root);
245
- return true;
246
- }
247
- }
248
-
249
- const sectionNotice = new SectionNotice();
250
-
251
- if (typeof document !== 'undefined') sectionNotice.init();
252
-
253
- export { sectionNotice, SectionNotice };
254
- export default sectionNotice;