@panphora/clayjs 1.1.0 → 1.3.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.
- package/README.md +4 -1
- package/THIRD-PARTY-NOTICES.md +10 -0
- package/dist/clay.standalone.js +21751 -13942
- package/entries/clay-data.js +1 -1
- package/package.json +7 -2
- package/packed-contract.json +17 -0
- package/src/attrs/save-freeze.js +10 -18
- package/src/core/admin-contenteditable.js +5 -6
- package/src/core/etag.js +17 -4
- package/src/core/host-attrs.js +52 -2
- package/src/core/is-edit-mode.js +14 -5
- package/src/core/persist.js +5 -10
- package/src/core/save-conflict-notice.js +22 -27
- package/src/core/save-core.js +326 -26
- package/src/core/save.js +19 -4
- package/src/core/snapshot.js +148 -15
- package/src/core/source-map.js +817 -0
- package/src/core/stale-host-notice.js +95 -0
- package/src/core/unsaved-warning.js +3 -0
- package/src/dom/dom-helpers.js +5 -1
- package/src/lib/content-dom.js +108 -0
- package/src/lib/hostile-css.js +49 -0
- package/src/lib/mutation.js +26 -3
- package/src/lib/region-capabilities.js +69 -0
- package/src/lib/region-policy.js +18 -13
- package/src/lib/root-attrs.js +52 -13
- package/src/loader-logic.js +25 -4
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +3 -0
- package/src/plugins/sortable.js +6 -1
- package/src/plugins/source.js +326 -0
- package/src/plugins/wire.js +248 -47
- package/src/sync/live-sync.js +275 -63
- package/src/sync/presence.js +303 -0
- package/src/sync/section-notice.js +230 -0
- package/src/sync/splice-merge.js +7 -10
- package/src/sync/stream.js +190 -0
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +438 -45
- package/src/vendor/parse5.vendor.js +3 -0
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +22 -15
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* presence.js — who else is on this document.
|
|
3
|
+
*
|
|
4
|
+
* A fixed-corner stack of circles, one per participant the host chose to name,
|
|
5
|
+
* plus a count of everyone it did not. The host decides who is named, per
|
|
6
|
+
* recipient, from that recipient's own access; nothing here asks for a name and
|
|
7
|
+
* nothing here can widen what arrived.
|
|
8
|
+
*
|
|
9
|
+
* THE RULE THIS FILE EXISTS TO GET RIGHT.
|
|
10
|
+
*
|
|
11
|
+
* captureForSaveAndComparison() clones the document, hands that clone to peers
|
|
12
|
+
* on `clay:snapshot-ready`, and only THEN strips the save-only regions. So
|
|
13
|
+
* `no-save` alone is not enough: it keeps names out of the file while
|
|
14
|
+
* broadcasting them to every peer on the document, including a visitor the host
|
|
15
|
+
* deliberately answered with a count and no names. `no-snapshot` is the token
|
|
16
|
+
* that runs BEFORE the clone is emitted (captureSnapshot), so every runtime root
|
|
17
|
+
* here carries all three, tooltip included:
|
|
18
|
+
*
|
|
19
|
+
* no-save not written to the saved file
|
|
20
|
+
* no-watch invisible to the mutation system, so a roster change is not an edit
|
|
21
|
+
* no-snapshot absent from every snapshot, including the one peers receive
|
|
22
|
+
*
|
|
23
|
+
* The roster lives in this module's memory. It is never written to a DOM data
|
|
24
|
+
* store, an attribute or anything else a serializer walks — the names on screen
|
|
25
|
+
* are text nodes inside a subtree that no snapshot contains, and the hover label
|
|
26
|
+
* is held in a closure rather than in `title`. Styling is inline and hostile-css
|
|
27
|
+
* !important throughout, so there is no injected <style> tag to mark either.
|
|
28
|
+
*
|
|
29
|
+
* Gated on the host, not on the first frame: a host that does not announce
|
|
30
|
+
* `presence` draws nothing at all rather than an empty stack. That is what
|
|
31
|
+
* hyperclay-local, HTML Clay and makerclay do today and it stays that way until
|
|
32
|
+
* they adopt it.
|
|
33
|
+
*
|
|
34
|
+
* `people[].id` is an opaque keyed pseudonym. It picks the colour and nothing
|
|
35
|
+
* else: it is not a database id, it names no account, and it is never logged.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { hostSupports } from '../core/host-meta.js';
|
|
39
|
+
import { make, set } from '../lib/hostile-css.js';
|
|
40
|
+
|
|
41
|
+
// Runtime-only chrome, in one string so no root can carry two of the three.
|
|
42
|
+
const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
|
|
43
|
+
|
|
44
|
+
// Two initials at 12px need about 15px of the circle, so the overlap has to
|
|
45
|
+
// leave that much clear or the neighbouring circle eats the second letter.
|
|
46
|
+
const SIZE = 30;
|
|
47
|
+
const OVERLAP = 6;
|
|
48
|
+
const FONT = "12px/1 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
49
|
+
const LABEL_FONT = "12px/1.4 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
50
|
+
|
|
51
|
+
// The chrome around the circles is themeable like every other clayjs surface.
|
|
52
|
+
// The circles themselves are not: their colour is computed per participant, so
|
|
53
|
+
// there is no one value a page could override.
|
|
54
|
+
const INK = 'var(--clay-presence-ink,#1f2023)';
|
|
55
|
+
const CHIP_BG = 'var(--clay-presence-bg,#fff)';
|
|
56
|
+
const CHIP_EDGE = 'var(--clay-presence-edge,rgba(0,0,0,.14))';
|
|
57
|
+
const TIP_BG = 'var(--clay-presence-tip-bg,#222)';
|
|
58
|
+
const TIP_INK = 'var(--clay-presence-tip-ink,#fff)';
|
|
59
|
+
const FACE = '#ffffff';
|
|
60
|
+
|
|
61
|
+
// A fixed set rather than a computed hue: every one of these was looked at
|
|
62
|
+
// against white initials, and a hue wheel puts neighbouring pseudonyms on two
|
|
63
|
+
// blues nobody can tell apart.
|
|
64
|
+
const COLORS = [
|
|
65
|
+
'#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e',
|
|
66
|
+
'#0369a1', '#1d4ed8', '#6d28d9', '#a21caf', '#9f1239',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Every element this module creates, marked the same way. */
|
|
70
|
+
function runtimeRoot(el) {
|
|
71
|
+
el.setAttribute('clay', RUNTIME_ONLY);
|
|
72
|
+
return el;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hashOf(id) {
|
|
76
|
+
let h = 5381;
|
|
77
|
+
for (let i = 0; i < id.length; i++) {
|
|
78
|
+
h = (((h << 5) + h) ^ id.charCodeAt(i)) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return h;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The pseudonym picks the colour and nothing else.
|
|
84
|
+
function colorFor(id) {
|
|
85
|
+
return COLORS[hashOf(id) % COLORS.length];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Array.from, not [0]: a name starting outside the BMP would otherwise show half
|
|
89
|
+
// a character.
|
|
90
|
+
function initialsOf(name) {
|
|
91
|
+
const words = String(name).trim().split(/\s+/).filter(Boolean);
|
|
92
|
+
if (!words.length) return '?';
|
|
93
|
+
const first = (word) => Array.from(word)[0].toUpperCase();
|
|
94
|
+
return words.length === 1 ? first(words[0]) : first(words[0]) + first(words[1]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read the roster frame defensively. Everything here arrived over a wire, and a
|
|
99
|
+
* frame this function cannot understand must draw nothing rather than draw a
|
|
100
|
+
* guess: `[object Object]` in a circle is worse than an empty corner.
|
|
101
|
+
*/
|
|
102
|
+
function normalize(data) {
|
|
103
|
+
const list = data && Array.isArray(data.people) ? data.people : [];
|
|
104
|
+
const people = [];
|
|
105
|
+
for (const person of list) {
|
|
106
|
+
if (!person || typeof person.id !== 'string' || !person.id) continue;
|
|
107
|
+
const name = typeof person.name === 'string' && person.name.trim() ? person.name.trim() : 'Someone';
|
|
108
|
+
people.push({
|
|
109
|
+
id: person.id,
|
|
110
|
+
name,
|
|
111
|
+
canEdit: person.canEdit === true,
|
|
112
|
+
you: person.you === true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const count = data && data.anonymous;
|
|
116
|
+
const anonymous = typeof count === 'number' && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
117
|
+
return { people, anonymous };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class Presence {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.roster = { people: [], anonymous: 0 };
|
|
123
|
+
this.root = null;
|
|
124
|
+
this.stack = null;
|
|
125
|
+
this.count = null;
|
|
126
|
+
this.tip = null;
|
|
127
|
+
this._gate = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Does this host run presence at all? Asked once, of /_/meta, and never
|
|
132
|
+
* inferred from a frame arriving: the answer decides whether the chrome is
|
|
133
|
+
* built, so a host that says nothing draws nothing forever.
|
|
134
|
+
*/
|
|
135
|
+
supported() {
|
|
136
|
+
if (!this._gate) this._gate = hostSupports('presence').then((ok) => ok === true, () => false);
|
|
137
|
+
return this._gate;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Take a roster frame. Held in memory first so a frame that arrives before
|
|
142
|
+
* discovery answers is not lost, and drawn only if the host announced the
|
|
143
|
+
* capability.
|
|
144
|
+
*
|
|
145
|
+
* @param {{people: Array, anonymous: number}} data
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
async update(data) {
|
|
149
|
+
this.roster = normalize(data);
|
|
150
|
+
if (!(await this.supported())) return;
|
|
151
|
+
this.render();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The stream went away, so the roster is no longer true about anything. */
|
|
155
|
+
clear() {
|
|
156
|
+
this.roster = { people: [], anonymous: 0 };
|
|
157
|
+
if (!this.root) return;
|
|
158
|
+
this.root.remove();
|
|
159
|
+
this.root = null;
|
|
160
|
+
this.stack = null;
|
|
161
|
+
this.count = null;
|
|
162
|
+
this.tip = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
render() {
|
|
166
|
+
const { people, anonymous } = this.roster;
|
|
167
|
+
|
|
168
|
+
// A person alone on a document is told nothing at all. The count is the
|
|
169
|
+
// total either way: a recipient the host would not name gets people: [] and
|
|
170
|
+
// everyone in `anonymous`, so this sum is the same participant count in both
|
|
171
|
+
// directions.
|
|
172
|
+
if (people.length + anonymous <= 1) {
|
|
173
|
+
this.hide();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!this.build()) return;
|
|
177
|
+
|
|
178
|
+
this.hideTip();
|
|
179
|
+
this.stack.textContent = '';
|
|
180
|
+
for (const person of people) {
|
|
181
|
+
this.stack.appendChild(this.avatar(person));
|
|
182
|
+
}
|
|
183
|
+
// The overlap belongs between circles, so the leftmost one does not pull
|
|
184
|
+
// itself into the count beside it.
|
|
185
|
+
if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
|
|
186
|
+
|
|
187
|
+
if (anonymous > 0) {
|
|
188
|
+
this.count.textContent = `+${anonymous} viewing`;
|
|
189
|
+
set(this.count, 'display', 'inline-block');
|
|
190
|
+
} else {
|
|
191
|
+
this.count.textContent = '';
|
|
192
|
+
set(this.count, 'display', 'none');
|
|
193
|
+
}
|
|
194
|
+
set(this.root, 'display', 'flex');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
hide() {
|
|
198
|
+
if (!this.root) return;
|
|
199
|
+
this.hideTip();
|
|
200
|
+
this.stack.textContent = '';
|
|
201
|
+
this.count.textContent = '';
|
|
202
|
+
set(this.root, 'display', 'none');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
build() {
|
|
206
|
+
if (this.root) return true;
|
|
207
|
+
if (typeof document === 'undefined' || !document.body) return false;
|
|
208
|
+
|
|
209
|
+
this.root = runtimeRoot(make('div', [
|
|
210
|
+
'position:fixed',
|
|
211
|
+
'top:calc(12px + env(safe-area-inset-top,0px))',
|
|
212
|
+
'right:calc(12px + env(safe-area-inset-right,0px))',
|
|
213
|
+
'z-index:2147483000',
|
|
214
|
+
'display:flex', 'align-items:center', 'gap:8px',
|
|
215
|
+
'max-width:calc(100vw - 24px)',
|
|
216
|
+
`font:${LABEL_FONT}`, `color:${INK}`,
|
|
217
|
+
// Only the circles take a pointer. A fixed corner that swallowed clicks
|
|
218
|
+
// would take a bite out of whatever the page put underneath it.
|
|
219
|
+
'pointer-events:none',
|
|
220
|
+
]));
|
|
221
|
+
this.root.setAttribute('data-clay-presence', '');
|
|
222
|
+
|
|
223
|
+
this.count = runtimeRoot(make('span', [
|
|
224
|
+
'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
|
|
225
|
+
'padding:4px 8px', 'border-radius:999px',
|
|
226
|
+
`background-color:${CHIP_BG}`, 'background-image:none', `color:${INK}`,
|
|
227
|
+
'border-width:1px', 'border-style:solid', `border-color:${CHIP_EDGE}`,
|
|
228
|
+
`font:${LABEL_FONT}`,
|
|
229
|
+
'box-shadow:0 1px 3px rgba(0,0,0,.2)',
|
|
230
|
+
'display:none',
|
|
231
|
+
]));
|
|
232
|
+
this.count.setAttribute('data-clay-presence-count', '');
|
|
233
|
+
|
|
234
|
+
this.stack = runtimeRoot(make('div', [
|
|
235
|
+
'display:flex', 'align-items:center', 'flex:none',
|
|
236
|
+
]));
|
|
237
|
+
|
|
238
|
+
// Inside the root rather than appended to <body>, so the one element that
|
|
239
|
+
// holds a name at rest cannot outlive the subtree that keeps it out of a
|
|
240
|
+
// snapshot. It carries the marking of its own too.
|
|
241
|
+
this.tip = runtimeRoot(make('span', [
|
|
242
|
+
'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
|
|
243
|
+
'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
|
|
244
|
+
'padding:4px 8px', 'border-radius:6px',
|
|
245
|
+
`background-color:${TIP_BG}`, 'background-image:none',
|
|
246
|
+
`color:${TIP_INK}`, `font:${LABEL_FONT}`,
|
|
247
|
+
'box-shadow:0 4px 14px rgba(0,0,0,.28)',
|
|
248
|
+
'display:none',
|
|
249
|
+
]));
|
|
250
|
+
this.tip.setAttribute('data-clay-presence-tip', '');
|
|
251
|
+
|
|
252
|
+
this.root.append(this.stack, this.count, this.tip);
|
|
253
|
+
document.body.appendChild(this.root);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
avatar(person) {
|
|
258
|
+
const color = colorFor(person.id);
|
|
259
|
+
// Solid says this person can change the document; hollow says they are
|
|
260
|
+
// reading it. The two are the same three colours inverted, so a stack of
|
|
261
|
+
// both reads as one set rather than as two kinds of thing.
|
|
262
|
+
const skin = person.canEdit
|
|
263
|
+
? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
|
|
264
|
+
: [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
|
|
265
|
+
|
|
266
|
+
const el = runtimeRoot(make('div', [
|
|
267
|
+
'box-sizing:border-box', 'flex:none',
|
|
268
|
+
`width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:50%',
|
|
269
|
+
'display:flex', 'align-items:center', 'justify-content:center',
|
|
270
|
+
`font:${FONT}`, 'font-weight:600',
|
|
271
|
+
`margin-left:-${OVERLAP}px`,
|
|
272
|
+
'border-width:2px', 'border-style:solid', 'background-image:none',
|
|
273
|
+
'box-shadow:0 1px 3px rgba(0,0,0,.28)',
|
|
274
|
+
'user-select:none', 'cursor:default', 'pointer-events:auto',
|
|
275
|
+
...skin,
|
|
276
|
+
], initialsOf(person.name)));
|
|
277
|
+
el.setAttribute('data-clay-presence-avatar', '');
|
|
278
|
+
|
|
279
|
+
// The name lives in this closure, never in an attribute on the element, and
|
|
280
|
+
// it reaches the DOM only while a pointer is actually on the circle.
|
|
281
|
+
const label = person.you ? `${person.name} (you)` : person.name;
|
|
282
|
+
el.addEventListener('mouseenter', () => this.showTip(label));
|
|
283
|
+
el.addEventListener('mouseleave', () => this.hideTip());
|
|
284
|
+
return el;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
showTip(text) {
|
|
288
|
+
if (!this.tip) return;
|
|
289
|
+
this.tip.textContent = text;
|
|
290
|
+
set(this.tip, 'display', 'block');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
hideTip() {
|
|
294
|
+
if (!this.tip) return;
|
|
295
|
+
this.tip.textContent = '';
|
|
296
|
+
set(this.tip, 'display', 'none');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const presence = new Presence();
|
|
301
|
+
|
|
302
|
+
export { presence, Presence };
|
|
303
|
+
export default presence;
|
|
@@ -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;
|
package/src/sync/splice-merge.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
fillCloneIds(
|
|
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
|
|