@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.
- package/README.md +2 -2
- package/dist/clay.standalone.js +7104 -4924
- package/entries/sap.js +6 -6
- package/package.json +1 -1
- package/src/attrs/add-from.js +72 -0
- package/src/core/conflict-notice.js +2 -5
- package/src/core/conflict-presentation.js +42 -0
- package/src/core/save-core.js +22 -0
- package/src/core/save.js +11 -2
- package/src/lib/region-capabilities.js +1 -0
- package/src/lib/region-policy.js +10 -3
- package/src/live/checkpoint.js +106 -0
- package/src/live/fast-lane.js +590 -0
- package/src/live/local-lane.js +582 -0
- package/src/live/ops.js +291 -0
- package/src/live/server-lane.js +84 -0
- package/src/live/store.js +291 -0
- package/src/live/stream-bridge.js +60 -0
- package/src/loader-logic.js +7 -2
- package/src/loader.js +2 -0
- package/src/plugins/live.js +446 -0
- package/src/sync/conflict-revert.js +1 -1
- package/src/sync/live-sync.js +21 -5
- package/src/sync/presence.js +4 -10
- package/src/sync/stream.js +2 -1
- package/src/vendor/hyper-morph.vendor.js +6 -4
- package/src/sync/section-notice.js +0 -254
|
@@ -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;
|