@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
package/src/core/snapshot.js
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* ┌─────────────────────────┐
|
|
31
31
|
* │ 4. SERIALIZE │
|
|
32
32
|
* │ "<!DOCTYPE html>" │
|
|
33
|
-
* │ + outerHTML
|
|
33
|
+
* │ + outerHTML, unless a │
|
|
34
|
+
* │ save renderer is set │
|
|
35
|
+
* │ (setSaveRenderer) │
|
|
34
36
|
* │ │
|
|
35
37
|
* │ → sent to server │
|
|
36
38
|
* └─────────────────────────┘
|
|
@@ -40,6 +42,7 @@ import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
|
40
42
|
import { restoreAuthoredUrls } from '../lib/authored-url.js';
|
|
41
43
|
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
42
44
|
import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
45
|
+
import { createContentView } from '../lib/content-dom.js';
|
|
43
46
|
|
|
44
47
|
// =============================================================================
|
|
45
48
|
// HOOK REGISTRIES
|
|
@@ -47,6 +50,20 @@ import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
|
47
50
|
|
|
48
51
|
const snapshotHooks = []; // Phase 2: Always run (form sync)
|
|
49
52
|
const documentTransforms = []; // Phase 3a: Save and change check (strip admin)
|
|
53
|
+
const snapshotProvenance = new WeakMap();
|
|
54
|
+
|
|
55
|
+
// Phase 4, and there is only ever one. Everything else in this file is a list
|
|
56
|
+
// because many things want a turn; serializing the document is one decision, so a
|
|
57
|
+
// second renderer would be two answers to "what bytes does this save send".
|
|
58
|
+
let saveRenderer = null;
|
|
59
|
+
|
|
60
|
+
export function originalSnapshotNode(cloneNode) {
|
|
61
|
+
return snapshotProvenance.get(cloneNode) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stripSnapshotRegions(clone) {
|
|
65
|
+
for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) el.remove();
|
|
66
|
+
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
52
69
|
* Run every authored handler of one kind over a clone, and never let one of them
|
|
@@ -63,6 +80,8 @@ const documentTransforms = []; // Phase 3a: Save and change check (strip admin)
|
|
|
63
80
|
*/
|
|
64
81
|
function runAuthoredHandlers(clone, attr) {
|
|
65
82
|
for (const el of clone.querySelectorAll(`[${attr}]`)) {
|
|
83
|
+
stripSnapshotRegions(clone);
|
|
84
|
+
if (el !== clone && !clone.contains(el)) continue;
|
|
66
85
|
try {
|
|
67
86
|
new Function(el.getAttribute(attr)).call(el);
|
|
68
87
|
} catch (err) {
|
|
@@ -81,6 +100,51 @@ export function onSnapshot(callback) {
|
|
|
81
100
|
snapshotHooks.push(callback);
|
|
82
101
|
}
|
|
83
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Replace the save-domain serializer.
|
|
105
|
+
*
|
|
106
|
+
* Given the prepared save clone and the bytes `clone.outerHTML` would have sent, it
|
|
107
|
+
* returns the bytes to send instead. The source map (plugins/source.js) is the only
|
|
108
|
+
* caller: it renders the file's own bytes back out with only the changed regions
|
|
109
|
+
* reprinted, so a save stops rewriting 88% of an authored document's lines.
|
|
110
|
+
*
|
|
111
|
+
* It runs on the save path with a person's document in its hands, so the contract is
|
|
112
|
+
* narrow. It must return a complete document. It must not touch the clone, which is
|
|
113
|
+
* also the source of both comparison baselines. And it must be able to answer with
|
|
114
|
+
* `today` unchanged, because that is what it has to do whenever it is not certain:
|
|
115
|
+
* the caller here treats a throw the same way, so the floor of any renderer is the
|
|
116
|
+
* serialization it replaced.
|
|
117
|
+
*
|
|
118
|
+
* Comparison and dirty-check captures deliberately do NOT go through it. Those
|
|
119
|
+
* strings are only ever compared against each other, so they gain nothing from
|
|
120
|
+
* source fidelity and would pay a full render on every keystroke's dirty check.
|
|
121
|
+
*
|
|
122
|
+
* @param {?Function} renderer - (clone, today) => string, or null to restore outerHTML
|
|
123
|
+
*/
|
|
124
|
+
export function setSaveRenderer(renderer) {
|
|
125
|
+
saveRenderer = renderer;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The bytes a save sends: the prepared clone, through the save renderer if one is
|
|
130
|
+
* installed.
|
|
131
|
+
*
|
|
132
|
+
* The try/catch is not defensive noise around a renderer that already catches its
|
|
133
|
+
* own errors. That one catches in order to COUNT, and to fire the event that says a
|
|
134
|
+
* document reprinted; this one is the guarantee that no bug in an optional plugin can
|
|
135
|
+
* cost somebody a save.
|
|
136
|
+
*/
|
|
137
|
+
function serializeSaveClone(clone) {
|
|
138
|
+
const today = "<!DOCTYPE html>" + clone.outerHTML;
|
|
139
|
+
if (!saveRenderer) return today;
|
|
140
|
+
try {
|
|
141
|
+
return saveRenderer(clone, today);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error('clayjs: save renderer failed, sending the full serialization', err);
|
|
144
|
+
return today;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
84
148
|
/**
|
|
85
149
|
* Register a transform that runs over a detached clone when preparing to save.
|
|
86
150
|
* Use for: stripping admin elements, cleanup.
|
|
@@ -116,7 +180,7 @@ function clonePreventingOnclone(node, deep = true) {
|
|
|
116
180
|
finally { window.__preventOnclone = prev; }
|
|
117
181
|
}
|
|
118
182
|
|
|
119
|
-
export function captureSnapshot({ flushUndo = true } = {}) {
|
|
183
|
+
export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
120
184
|
// Force-close any pending undo idle batch BEFORE cloning the DOM, so the
|
|
121
185
|
// snapshot reflects a clean undo boundary. Without this, a save that fires
|
|
122
186
|
// mid-typing would leave the idle batch open across the save boundary, and
|
|
@@ -129,10 +193,27 @@ export function captureSnapshot({ flushUndo = true } = {}) {
|
|
|
129
193
|
window.clay.undo.flush();
|
|
130
194
|
}
|
|
131
195
|
|
|
132
|
-
const
|
|
196
|
+
const view = createContentView(document.documentElement, { capability: 'snapshot' });
|
|
197
|
+
const clone = view.root;
|
|
198
|
+
// A <template>'s children are not its childNodes, they are its content fragment's,
|
|
199
|
+
// so walking childNodes alone leaves everything inside a template with no provenance.
|
|
200
|
+
// Nothing noticed until the source map asked which live node a clone node came from
|
|
201
|
+
// and got null for every node in a template, which reprinted the whole template on
|
|
202
|
+
// every save. createContentView already maps the fragment itself, so descending into
|
|
203
|
+
// it is all that was missing.
|
|
204
|
+
const remember = (node) => {
|
|
205
|
+
const live = view.original(node);
|
|
206
|
+
if (live) snapshotProvenance.set(node, live);
|
|
207
|
+
const children = node.nodeType === 1 && node.tagName === 'TEMPLATE' && node.content
|
|
208
|
+
? node.content.childNodes
|
|
209
|
+
: node.childNodes;
|
|
210
|
+
for (const child of children || []) remember(child);
|
|
211
|
+
};
|
|
212
|
+
remember(clone);
|
|
133
213
|
|
|
134
214
|
for (const hook of snapshotHooks) {
|
|
135
|
-
|
|
215
|
+
stripSnapshotRegions(clone);
|
|
216
|
+
hook(clone, { original: originalSnapshotNode });
|
|
136
217
|
}
|
|
137
218
|
|
|
138
219
|
// Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
|
|
@@ -140,11 +221,13 @@ export function captureSnapshot({ flushUndo = true } = {}) {
|
|
|
140
221
|
// authored handler sees the same URLs the file will.
|
|
141
222
|
restoreAuthoredUrls(clone);
|
|
142
223
|
|
|
143
|
-
|
|
224
|
+
stripSnapshotRegions(clone);
|
|
225
|
+
// authored: false is for a capture that is not a save and not a frame the page will
|
|
226
|
+
// see. The attributes hold page-author JavaScript, so running them is a side effect
|
|
227
|
+
// the author asked for once per save, not once per capture.
|
|
228
|
+
if (authored) runAuthoredHandlers(clone, 'onbeforesnapshot');
|
|
144
229
|
|
|
145
|
-
|
|
146
|
-
el.remove();
|
|
147
|
-
}
|
|
230
|
+
stripSnapshotRegions(clone);
|
|
148
231
|
|
|
149
232
|
// Browser-extension noise (password-manager menus, Grammarly overlays, and
|
|
150
233
|
// marker attributes on real inputs) is not page content. Drop it from every
|
|
@@ -159,17 +242,21 @@ export function captureSnapshot({ flushUndo = true } = {}) {
|
|
|
159
242
|
* Mutates the clone — only call once per snapshot.
|
|
160
243
|
*
|
|
161
244
|
* @param {HTMLElement} clone - A snapshot from captureSnapshot()
|
|
162
|
-
* @returns {
|
|
245
|
+
* @returns {HTMLElement} The same clone, in the save domain
|
|
163
246
|
*/
|
|
164
|
-
function prepareCloneForSave(clone) {
|
|
247
|
+
function prepareCloneForSave(clone, { authored = true } = {}) {
|
|
248
|
+
stripSnapshotRegions(clone);
|
|
165
249
|
// Run inline [onbeforesave] handlers
|
|
166
|
-
runAuthoredHandlers(clone, 'onbeforesave');
|
|
250
|
+
if (authored) runAuthoredHandlers(clone, 'onbeforesave');
|
|
167
251
|
|
|
168
252
|
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
169
253
|
for (const hook of documentTransforms) {
|
|
254
|
+
stripSnapshotRegions(clone);
|
|
170
255
|
hook(clone);
|
|
171
256
|
}
|
|
172
257
|
|
|
258
|
+
stripSnapshotRegions(clone);
|
|
259
|
+
|
|
173
260
|
// Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
|
|
174
261
|
// prepare hook (freeze restore) can re-inject [no-save] content into the clone,
|
|
175
262
|
// so the strip must run after the hooks or that content leaks to disk.
|
|
@@ -177,7 +264,29 @@ function prepareCloneForSave(clone) {
|
|
|
177
264
|
el.remove();
|
|
178
265
|
}
|
|
179
266
|
|
|
180
|
-
return
|
|
267
|
+
return clone;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The save-domain clone, for a caller that needs the tree rather than the bytes.
|
|
272
|
+
*
|
|
273
|
+
* The source map pairs against this rather than against the live DOM, because this is
|
|
274
|
+
* the tree in the file's own domain: edit mode deactivated back to the inert
|
|
275
|
+
* attribute forms, [no-save] gone, every transform run. Every node in it that came
|
|
276
|
+
* from the page can be traced back with originalSnapshotNode.
|
|
277
|
+
*
|
|
278
|
+
* Emits no snapshot-ready event: this capture must never feed the send pipeline.
|
|
279
|
+
*
|
|
280
|
+
* A caller that is only INSPECTING the tree passes `{ flushUndo: false, authored:
|
|
281
|
+
* false }`. Both defaults are right for a save and wrong for anything else: flushing
|
|
282
|
+
* closes the undo batch, so a capture taken mid-typing splits the user's undo history
|
|
283
|
+
* at a point they did not make, and the authored handlers are page JavaScript that is
|
|
284
|
+
* meant to run once per save, not once per capture.
|
|
285
|
+
*
|
|
286
|
+
* @returns {HTMLElement}
|
|
287
|
+
*/
|
|
288
|
+
export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
|
|
289
|
+
return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
|
|
181
290
|
}
|
|
182
291
|
|
|
183
292
|
/**
|
|
@@ -202,9 +311,12 @@ export function captureForComparison({ flushUndo = true } = {}) {
|
|
|
202
311
|
|
|
203
312
|
// Run registered prepare hooks
|
|
204
313
|
for (const hook of documentTransforms) {
|
|
314
|
+
stripSnapshotRegions(clone);
|
|
205
315
|
hook(clone);
|
|
206
316
|
}
|
|
207
317
|
|
|
318
|
+
stripSnapshotRegions(clone);
|
|
319
|
+
|
|
208
320
|
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
209
321
|
}
|
|
210
322
|
|
|
@@ -232,9 +344,12 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
|
|
|
232
344
|
}
|
|
233
345
|
|
|
234
346
|
for (const hook of documentTransforms) {
|
|
347
|
+
stripSnapshotRegions(clone);
|
|
235
348
|
hook(clone);
|
|
236
349
|
}
|
|
237
350
|
|
|
351
|
+
stripSnapshotRegions(clone);
|
|
352
|
+
|
|
238
353
|
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
239
354
|
}
|
|
240
355
|
|
|
@@ -262,8 +377,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
262
377
|
el.remove();
|
|
263
378
|
}
|
|
264
379
|
for (const hook of documentTransforms) {
|
|
380
|
+
stripSnapshotRegions(clone);
|
|
265
381
|
hook(clone);
|
|
266
382
|
}
|
|
383
|
+
stripSnapshotRegions(clone);
|
|
267
384
|
const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
|
|
268
385
|
|
|
269
386
|
let forDirty = forComparison;
|
|
@@ -272,8 +389,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
272
389
|
el.remove();
|
|
273
390
|
}
|
|
274
391
|
for (const hook of documentTransforms) {
|
|
392
|
+
stripSnapshotRegions(dirtyClone);
|
|
275
393
|
hook(dirtyClone);
|
|
276
394
|
}
|
|
395
|
+
stripSnapshotRegions(dirtyClone);
|
|
277
396
|
forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
|
|
278
397
|
}
|
|
279
398
|
|
|
@@ -324,20 +443,24 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
324
443
|
// Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
|
|
325
444
|
// LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
|
|
326
445
|
for (const hook of documentTransforms) {
|
|
446
|
+
stripSnapshotRegions(clone);
|
|
327
447
|
hook(clone);
|
|
328
448
|
}
|
|
449
|
+
stripSnapshotRegions(clone);
|
|
329
450
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
330
451
|
el.remove();
|
|
331
452
|
}
|
|
332
|
-
const forSave =
|
|
453
|
+
const forSave = serializeSaveClone(clone);
|
|
333
454
|
|
|
334
455
|
// Compare clone: strip every autosave-off region, then run hooks
|
|
335
456
|
for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
336
457
|
el.remove();
|
|
337
458
|
}
|
|
338
459
|
for (const hook of documentTransforms) {
|
|
460
|
+
stripSnapshotRegions(compareClone);
|
|
339
461
|
hook(compareClone);
|
|
340
462
|
}
|
|
463
|
+
stripSnapshotRegions(compareClone);
|
|
341
464
|
const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
|
|
342
465
|
|
|
343
466
|
// Dirty clone: same shape as the compare clone, one selector weaker.
|
|
@@ -347,8 +470,10 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
347
470
|
el.remove();
|
|
348
471
|
}
|
|
349
472
|
for (const hook of documentTransforms) {
|
|
473
|
+
stripSnapshotRegions(dirtyClone);
|
|
350
474
|
hook(dirtyClone);
|
|
351
475
|
}
|
|
476
|
+
stripSnapshotRegions(dirtyClone);
|
|
352
477
|
forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
|
|
353
478
|
}
|
|
354
479
|
|
|
@@ -400,8 +525,10 @@ export function captureForMerge() {
|
|
|
400
525
|
})(compareClone, clone);
|
|
401
526
|
|
|
402
527
|
for (const hook of documentTransforms) {
|
|
528
|
+
stripSnapshotRegions(clone);
|
|
403
529
|
hook(clone);
|
|
404
530
|
}
|
|
531
|
+
stripSnapshotRegions(clone);
|
|
405
532
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
406
533
|
el.remove();
|
|
407
534
|
}
|
|
@@ -410,8 +537,10 @@ export function captureForMerge() {
|
|
|
410
537
|
el.remove();
|
|
411
538
|
}
|
|
412
539
|
for (const hook of documentTransforms) {
|
|
540
|
+
stripSnapshotRegions(compareClone);
|
|
413
541
|
hook(compareClone);
|
|
414
542
|
}
|
|
543
|
+
stripSnapshotRegions(compareClone);
|
|
415
544
|
|
|
416
545
|
return { saveClone: clone, compareClone, pairMap };
|
|
417
546
|
}
|
|
@@ -437,7 +566,7 @@ export function captureForSave({ emitForSync = true } = {}) {
|
|
|
437
566
|
}));
|
|
438
567
|
}
|
|
439
568
|
|
|
440
|
-
return prepareCloneForSave(clone);
|
|
569
|
+
return serializeSaveClone(prepareCloneForSave(clone));
|
|
441
570
|
}
|
|
442
571
|
|
|
443
572
|
/**
|
|
@@ -477,7 +606,11 @@ export function serializeForSync(clone) {
|
|
|
477
606
|
// truncate the broadcast.
|
|
478
607
|
const shell = bareRoot.outerHTML;
|
|
479
608
|
const endTag = `</${bareRoot.localName}>`;
|
|
480
|
-
|
|
609
|
+
// The doctype, so this artifact is a complete document like every other one this
|
|
610
|
+
// module produces. Spec section 2 asks for that, and it costs nothing on the wire:
|
|
611
|
+
// a receiver parses the string and morphs documentElement against documentElement,
|
|
612
|
+
// so the prologue is consumed by the parser and never reaches the morph.
|
|
613
|
+
return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
|
|
481
614
|
}
|
|
482
615
|
|
|
483
616
|
/**
|