@panphora/clayjs 1.3.0 → 1.5.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 +3 -2
- package/dist/clay.standalone.js +6972 -3942
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +6 -4
- package/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -1
- package/src/core/admin-inputs.js +23 -13
- package/src/core/admin-onclick.js +5 -0
- package/src/core/autosave.js +2 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/save.js +22 -4
- package/src/core/snapshot.js +46 -3
- package/src/core/source-map.js +246 -38
- package/src/lib/autosave-state.js +17 -0
- package/src/lib/dirty-gate.js +5 -0
- package/src/loader-logic.js +8 -2
- package/src/loader.js +2 -0
- package/src/plugins/ai-edit.js +625 -0
- package/src/plugins/source.js +150 -66
- package/src/sync/live-sync.js +467 -311
- package/src/sync/section-notice.js +6 -7
- package/src/vendor/control-serialize.vendor.js +13 -6
- package/src/vendor/hyper-morph.vendor.js +4 -9
- package/src/vendor/hypercms.vendor.js +38 -38
- package/src/sync/splice-merge.js +0 -270
package/src/plugins/source.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
model,
|
|
43
43
|
checkSource,
|
|
44
44
|
locate,
|
|
45
|
+
nodeAt,
|
|
45
46
|
pair,
|
|
46
47
|
render,
|
|
47
48
|
verify
|
|
@@ -62,6 +63,11 @@ const state = {
|
|
|
62
63
|
saves: 0,
|
|
63
64
|
reprints: 0,
|
|
64
65
|
lastReprint: null,
|
|
66
|
+
partialReprints: 0,
|
|
67
|
+
lastPartialReprint: null,
|
|
68
|
+
// What the last render produced, so the save the host accepts can be counted and
|
|
69
|
+
// announced as what it was. Cleared once reported.
|
|
70
|
+
lastOutcome: null,
|
|
65
71
|
lastRenderMs: null,
|
|
66
72
|
lastBytes: null,
|
|
67
73
|
timing: { fetch: null, model: null, pair: null, refresh: null },
|
|
@@ -154,38 +160,95 @@ function install(sourceUrl) {
|
|
|
154
160
|
* pass as "the verifier is blind" is a mistake this project has already made once.
|
|
155
161
|
* `{ corrupt: 'drop-first-text' }` is the one that must be REJECTED: it changes the
|
|
156
162
|
* tree, which is exactly what the verifier is for.
|
|
163
|
+
*
|
|
164
|
+
* A render that does not verify is not thrown away whole. verify says where it first
|
|
165
|
+
* differs; that element is printed in full and the render tried again, widening to the
|
|
166
|
+
* parent while it still fails. Everything outside the printed elements is still the
|
|
167
|
+
* author's bytes. Only when the widening reaches <html>, or the failure has no node to
|
|
168
|
+
* point at (the prologue, a parse-error count), is today's full serialization sent.
|
|
169
|
+
*
|
|
170
|
+
* Nothing is counted or announced here. A render is not a save: an autosave that
|
|
171
|
+
* found nothing changed, a refused save and a failed request all rendered. The
|
|
172
|
+
* outcome is recorded, and `adopt` reports it when the host accepts these exact bytes.
|
|
157
173
|
*/
|
|
158
174
|
export function renderSave(clone, today, opts = {}) {
|
|
159
175
|
if (!state.installed) return today;
|
|
160
|
-
state.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
state.lastOutcome = null;
|
|
177
|
+
const print = new Set();
|
|
178
|
+
let firstDiff = null;
|
|
179
|
+
for (let round = 0; round <= MAX_PRINT_ROUNDS; round++) {
|
|
180
|
+
let out;
|
|
181
|
+
try {
|
|
182
|
+
out = render(clone, state.map, state.model, originalSnapshotNode, { ...opts, print });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return fallback('render threw: ' + (err && err.message ? err.message : err), today);
|
|
185
|
+
}
|
|
186
|
+
const v = verify(out.text, today, document, clone, state.model.parseErrors);
|
|
187
|
+
if (v.ok) {
|
|
188
|
+
state.lastRenderMs = out.ms;
|
|
189
|
+
state.lastBytes = out.text.length;
|
|
190
|
+
state.lastOutcome = print.size
|
|
191
|
+
? { text: out.text, scope: 'partial', reason: firstDiff, printed: print.size }
|
|
192
|
+
: { text: out.text, scope: null };
|
|
193
|
+
return out.text;
|
|
194
|
+
}
|
|
195
|
+
if (firstDiff === null) firstDiff = v.diff;
|
|
196
|
+
if (!v.at || !widen(clone, nodeAt(clone, v.at), print)) return fallback(firstDiff, today);
|
|
197
|
+
}
|
|
198
|
+
return fallback(firstDiff, today);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const MAX_PRINT_ROUNDS = 8;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Add the next element to print: the one holding `node`, or, when that is already
|
|
205
|
+
* inside a printed element, that element's parent. false when the only element left is
|
|
206
|
+
* the root, which is the full serialization by another name.
|
|
207
|
+
*/
|
|
208
|
+
function widen(clone, node, print) {
|
|
209
|
+
let el = node && node.nodeType === 1 ? node : node && node.parentNode;
|
|
210
|
+
for (let a = el; a && a !== clone; a = a.parentNode) {
|
|
211
|
+
if (print.has(a)) { print.delete(a); el = a.parentNode; break; }
|
|
166
212
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
213
|
+
if (!el || el.nodeType !== 1 || el === clone) return false;
|
|
214
|
+
print.add(el);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Send today's full serialization, and remember that this render was a fallback. */
|
|
219
|
+
function fallback(reason, today) {
|
|
220
|
+
state.lastOutcome = { text: today, scope: 'full', reason };
|
|
221
|
+
return today;
|
|
172
222
|
}
|
|
173
223
|
|
|
174
224
|
/**
|
|
175
225
|
* The fallback, and the alarm.
|
|
176
226
|
*
|
|
177
|
-
*
|
|
178
|
-
* the behaviour it replaces. The event and the
|
|
227
|
+
* Reported for a save the host accepted. The bytes that went out were today's, so the
|
|
228
|
+
* floor of this whole mechanism is the behaviour it replaces. The event and the
|
|
229
|
+
* counter are how a reprint gets fixed
|
|
179
230
|
* in the renderer or in the page, which is the only place it should ever be fixed: a
|
|
180
231
|
* verifier loosened to make this counter look better would pass exactly the writes it
|
|
181
232
|
* exists to stop.
|
|
182
233
|
*/
|
|
183
|
-
function reprint(reason
|
|
234
|
+
function reprint(reason) {
|
|
184
235
|
state.reprints++;
|
|
185
236
|
state.lastReprint = reason;
|
|
186
237
|
console.warn('clayjs: source map did not verify, saving the full serialization instead:', reason);
|
|
187
|
-
document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason } }));
|
|
188
|
-
|
|
238
|
+
document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason, scope: 'full' } }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Part of the document was printed rather than copied. Counted apart from a full
|
|
243
|
+
* reprint because they are different news: this one kept the author's bytes everywhere
|
|
244
|
+
* else, and its rate says how often the renderer and the page disagree about one
|
|
245
|
+
* element, which is where the next renderer fix is.
|
|
246
|
+
*/
|
|
247
|
+
function partialReprint(reason, printed) {
|
|
248
|
+
state.partialReprints++;
|
|
249
|
+
state.lastPartialReprint = reason;
|
|
250
|
+
console.info(`clayjs: source map printed ${printed} element(s) in full to match the page:`, reason);
|
|
251
|
+
document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason, scope: 'partial', printed } }));
|
|
189
252
|
}
|
|
190
253
|
|
|
191
254
|
/**
|
|
@@ -196,28 +259,41 @@ function reprint(reason, today) {
|
|
|
196
259
|
* and the page being told about it. Only the most recent accepted bytes matter, so a
|
|
197
260
|
* burst of saves costs one refresh.
|
|
198
261
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* htmlclay strips the save token from the root tag, which was never meant to reach
|
|
202
|
-
* disk, so the model carries one attribute the file does not. It is self-correcting
|
|
203
|
-
* rather than cumulative — the next render copies the token's bytes back out of the
|
|
204
|
-
* model and the host strips them again — so the file stays put and only the model's
|
|
205
|
-
* idea of the root tag is one attribute long.
|
|
262
|
+
* It is also where a save is counted, because it is the one place that knows a save
|
|
263
|
+
* reached the file.
|
|
206
264
|
*/
|
|
207
265
|
function adopt(bytes) {
|
|
208
266
|
if (!state.installed) return;
|
|
267
|
+
report(bytes);
|
|
209
268
|
state.pendingBytes = bytes;
|
|
210
269
|
scheduleRefresh();
|
|
211
270
|
}
|
|
212
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Count and announce an accepted save, if these are the bytes the last render
|
|
274
|
+
* produced. Bytes this module did not render (a caller of `saveHtml` with its own
|
|
275
|
+
* string, or an older render) are not its save to report.
|
|
276
|
+
*/
|
|
277
|
+
function report(bytes) {
|
|
278
|
+
const outcome = state.lastOutcome;
|
|
279
|
+
if (!outcome || outcome.text !== bytes) return;
|
|
280
|
+
state.lastOutcome = null;
|
|
281
|
+
state.saves++;
|
|
282
|
+
if (outcome.scope === 'full') reprint(outcome.reason);
|
|
283
|
+
else if (outcome.scope === 'partial') partialReprint(outcome.reason, outcome.printed);
|
|
284
|
+
}
|
|
285
|
+
|
|
213
286
|
/**
|
|
214
287
|
* A morph replaced live nodes, so the map is keyed by objects that are no longer in
|
|
215
|
-
* the page
|
|
216
|
-
*
|
|
217
|
-
* the
|
|
288
|
+
* the page, and it has to be re-paired either way. A DISK frame also carries the bytes
|
|
289
|
+
* now on disk, written by somebody else: those become the model, the same as bytes this
|
|
290
|
+
* tab saved, or the next save would copy the old formatting back over theirs. A peer
|
|
291
|
+
* frame changed nothing on disk, so the model stays.
|
|
218
292
|
*/
|
|
219
|
-
function queueRepair() {
|
|
293
|
+
function queueRepair(event) {
|
|
220
294
|
if (!state.installed) return;
|
|
295
|
+
const detail = event && event.detail;
|
|
296
|
+
if (detail && detail.source === 'disk' && typeof detail.html === 'string') state.pendingBytes = detail.html;
|
|
221
297
|
scheduleRefresh();
|
|
222
298
|
}
|
|
223
299
|
|
|
@@ -232,45 +308,51 @@ function queueRepair() {
|
|
|
232
308
|
function scheduleRefresh() {
|
|
233
309
|
if (state.refreshQueued) return;
|
|
234
310
|
state.refreshQueued = true;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
311
|
+
if (typeof requestIdleCallback === 'function') requestIdleCallback(refreshNow, { timeout: 2000 });
|
|
312
|
+
else setTimeout(refreshNow, 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Run a queued refresh now. A no-op when none is queued, which is also what makes the
|
|
317
|
+
* idle callback of a refresh that `text()` or `locate()` already ran harmless.
|
|
318
|
+
*/
|
|
319
|
+
function refreshNow() {
|
|
320
|
+
if (!state.refreshQueued) return;
|
|
321
|
+
state.refreshQueued = false;
|
|
322
|
+
if (!state.installed) return;
|
|
323
|
+
const bytes = state.pendingBytes;
|
|
324
|
+
state.pendingBytes = null;
|
|
325
|
+
const t = now();
|
|
326
|
+
// The two halves fail independently, so they are tried independently. A refused
|
|
327
|
+
// re-model used to skip the re-pair with it, and the re-pair is the half that
|
|
328
|
+
// cannot be skipped: a morph replaced live nodes, so the old map is keyed by
|
|
329
|
+
// objects no longer in the page, and every save after that reprints the whole
|
|
330
|
+
// document until something else queues a refresh. The previous model still
|
|
331
|
+
// describes real bytes, so re-pairing against it is the right answer.
|
|
332
|
+
let m = state.model;
|
|
333
|
+
if (bytes !== null) {
|
|
258
334
|
try {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
state.refreshes++;
|
|
264
|
-
state.timing.refresh = now() - t;
|
|
335
|
+
const next = model(bytes);
|
|
336
|
+
const refused = checkSource(next, document);
|
|
337
|
+
if (refused) throw new Error(refused);
|
|
338
|
+
m = next;
|
|
265
339
|
} catch (err) {
|
|
266
|
-
|
|
267
|
-
// the last successful refresh. Renders off a stale map verify or fall back like
|
|
268
|
-
// any other, so this costs formatting fidelity and nothing else.
|
|
269
|
-
console.warn('clayjs: source map could not re-pair, continuing on the previous map:', err);
|
|
340
|
+
console.warn('clayjs: source map kept the previous model, the accepted bytes did not model:', err);
|
|
270
341
|
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const { map, stats } = pairAgainstPage(m);
|
|
345
|
+
state.model = m;
|
|
346
|
+
state.map = map;
|
|
347
|
+
state.stats = stats;
|
|
348
|
+
state.refreshes++;
|
|
349
|
+
state.timing.refresh = now() - t;
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// The map is now stale rather than wrong: it still describes the pairing as of
|
|
352
|
+
// the last successful refresh. Renders off a stale map verify or fall back like
|
|
353
|
+
// any other, so this costs formatting fidelity and nothing else.
|
|
354
|
+
console.warn('clayjs: source map could not re-pair, continuing on the previous map:', err);
|
|
355
|
+
}
|
|
274
356
|
}
|
|
275
357
|
|
|
276
358
|
function summary() {
|
|
@@ -287,6 +369,8 @@ function summary() {
|
|
|
287
369
|
saves: state.saves,
|
|
288
370
|
reprints: state.reprints,
|
|
289
371
|
lastReprint: state.lastReprint,
|
|
372
|
+
partialReprints: state.partialReprints,
|
|
373
|
+
lastPartialReprint: state.lastPartialReprint,
|
|
290
374
|
lastRenderMs: state.lastRenderMs,
|
|
291
375
|
lastBytes: state.lastBytes,
|
|
292
376
|
refreshes: state.refreshes,
|
|
@@ -297,8 +381,8 @@ function summary() {
|
|
|
297
381
|
export const source = {
|
|
298
382
|
ready: null,
|
|
299
383
|
stats: summary,
|
|
300
|
-
/** The bytes this module believes are on disk right now. */
|
|
301
|
-
text: () => (state.model ? state.model.src : null
|
|
384
|
+
/** The bytes this module believes are on disk right now. A refresh still queued from a save or a disk frame runs first, so this is never the file before that. */
|
|
385
|
+
text: () => { refreshNow(); return state.model ? state.model.src : null; },
|
|
302
386
|
/**
|
|
303
387
|
* Where a live element is in those bytes: `{ from, to, line, column }`, or null.
|
|
304
388
|
*
|
|
@@ -312,7 +396,7 @@ export const source = {
|
|
|
312
396
|
* agent that cannot tell "this element is new" from "this document has no map" will eventually
|
|
313
397
|
* write into a file it was never modelling.
|
|
314
398
|
*/
|
|
315
|
-
locate: (node) => (state.installed && node ? locate(node, state.map, state.model) : null
|
|
399
|
+
locate: (node) => { refreshNow(); return state.installed && node ? locate(node, state.map, state.model) : null; },
|
|
316
400
|
/** What did not pair, for working out why a document reprints more than it should. */
|
|
317
401
|
unpaired: () => (state.stats
|
|
318
402
|
? { live: state.stats.unmatchedLive.slice(0, 50), source: state.stats.unmatchedSource.slice(0, 50) }
|