@panphora/clayjs 1.24.0 → 1.24.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/dist/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +15016 -13603
- package/dist/m/c-277QP5ET.js +1 -0
- package/dist/m/{c-BULK5J4M.js → c-3UW5EJ77.js} +1 -1
- package/dist/m/{c-GM7OA6EJ.js → c-42ZNV7LI.js} +1 -1
- package/dist/m/{c-RXTPKCMC.js → c-6IJEVE6B.js} +1 -1
- package/dist/m/{c-2R4OBWIP.js → c-6LEYVQJK.js} +1 -1
- package/dist/m/{c-LO4C4J77.js → c-6Q6RGFBF.js} +1 -1
- package/dist/m/{c-PD5AKHY3.js → c-7CM4GMTS.js} +1 -1
- package/dist/m/c-APGHGDZM.js +1 -0
- package/dist/m/{c-Q6VNQDJE.js → c-B7GOFGQR.js} +1 -1
- package/dist/m/{c-RJKKRWHW.js → c-BGN7RCTJ.js} +1 -1
- package/dist/m/{c-A7N5TNSL.js → c-C5AKJVRH.js} +1 -1
- package/dist/m/{c-SNVDWMM3.js → c-CJ7QYEWI.js} +1 -1
- package/dist/m/{c-PVJMJL3P.js → c-CZHTXGJS.js} +1 -1
- package/dist/m/{c-KJHVB2KG.js → c-DDS23EY6.js} +1 -1
- package/dist/m/{c-VSEUFJAO.js → c-DFKBGSN2.js} +1 -1
- package/dist/m/{c-DLBX4JCJ.js → c-EDE45NM7.js} +1 -1
- package/dist/m/c-EILLN2H2.js +1 -0
- package/dist/m/{c-EY5KDUZF.js → c-FEC6LGFF.js} +1 -1
- package/dist/m/{c-PMRRMT72.js → c-HDVH3MVC.js} +1 -1
- package/dist/m/c-HKSLOVU4.js +1 -0
- package/dist/m/{c-GNIVHLEJ.js → c-HL6BLJNA.js} +1 -1
- package/dist/m/c-HTLYXCUH.js +1 -0
- package/dist/m/{c-RTSRGGTP.js → c-JQF2EUCG.js} +1 -1
- package/dist/m/{c-O5ARALMJ.js → c-JWDCQL6J.js} +1 -1
- package/dist/m/{c-WOELSNWS.js → c-LATNQ6Q5.js} +1 -1
- package/dist/m/{c-UM7VZDTW.js → c-LSVN3OLG.js} +1 -1
- package/dist/m/{c-HSNGLIFB.js → c-MFOSMAVM.js} +2 -2
- package/dist/m/{c-FZRZPIFS.js → c-MQYEDF3Q.js} +1 -1
- package/dist/m/{c-2OIKXOY6.js → c-ND3Q2G2I.js} +1 -1
- package/dist/m/{c-PZRWZZRO.js → c-OCYG4NBQ.js} +1 -1
- package/dist/m/{c-54ZZZYUN.js → c-P2HKMJAW.js} +1 -1
- package/dist/m/{c-VI3BJHCM.js → c-PCNJETUA.js} +1 -1
- package/dist/m/{c-7YETSAVY.js → c-PDBNCJCT.js} +1 -1
- package/dist/m/{c-A5ZBFICF.js → c-PYG2NGWU.js} +1 -1
- package/dist/m/{c-RBPZNU2D.js → c-QQFHEODI.js} +1 -1
- package/dist/m/{c-YINHPF4V.js → c-SGH3UTF3.js} +1 -1
- package/dist/m/{c-FV46ZKGX.js → c-SKMIR5HQ.js} +1 -1
- package/dist/m/{c-TWOXQQKV.js → c-STRI73GQ.js} +1 -1
- package/dist/m/{c-75N3KG35.js → c-T3P3AXEW.js} +1 -1
- package/dist/m/c-U4NTUVDZ.js +1 -0
- package/dist/m/{c-P5NKFRL4.js → c-UOOIF2AL.js} +1 -1
- package/dist/m/c-VHSMPITY.js +1 -0
- package/dist/m/{c-6UPEKW6O.js → c-X75G43SZ.js} +1 -1
- package/dist/m/{c-YGGO2G2L.js → c-XW5TIYYB.js} +1 -1
- package/dist/m/{c-DZPO22O3.js → c-YDW4VCHP.js} +1 -1
- package/dist/m/{c-YB3CDTDZ.js → c-YLHTD3KX.js} +1 -1
- package/dist/m/c-Z4G2UK6T.js +4 -0
- package/dist/m/{c-VNAYHV2J.js → c-ZGCO4J3H.js} +1 -1
- package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ONRWVXPK.js} +1 -1
- package/dist/m/core/{autosave-4P66VXX7.js → autosave-TBKAAAFB.js} +1 -1
- package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-FOEE6I7E.js} +1 -1
- package/dist/m/core/draft-mode-4MCL3LYI.js +1 -0
- package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-X4BENZEU.js} +1 -1
- package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-FFHCQ5DR.js} +1 -1
- package/dist/m/core/{persist-AIJGTKKI.js → persist-6ILUR34S.js} +1 -1
- package/dist/m/core/{recovery-LPB6X72P.js → recovery-APWADU4K.js} +1 -1
- package/dist/m/core/{save-FV4ZCKBI.js → save-6HT2LABE.js} +1 -1
- package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-EVMNZQ4V.js} +1 -1
- package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-G4BOZMV4.js} +1 -1
- package/dist/m/core/{unsaved-warning-FF63QGKI.js → unsaved-warning-ITZDIZ2I.js} +1 -1
- package/dist/m/internals/{index-AX4CG7B2.js → index-HS3CQDFW.js} +1 -1
- package/dist/m/loader-GLLVDUZA.js +1 -0
- package/dist/m/manifest-50FF1C45.js +1 -0
- package/dist/m/manifest-edit-BE1A621D.js +1 -0
- package/dist/m/manifest.json +710 -674
- package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-LABECCSN.js} +1 -1
- package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
- package/dist/m/plugins/{demo-JIR7DXJA.js → demo-T7B4RIKL.js} +1 -1
- package/dist/m/plugins/{derive-QVI3LI3A.js → derive-6LPJOCAV.js} +1 -1
- package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-J4ABFARX.js} +1 -1
- package/dist/m/plugins/{live-UAJGWRNP.js → live-PGBIEMGB.js} +1 -1
- package/dist/m/plugins/{menu-BFJ644YX.js → menu-YZL3X2AJ.js} +1 -1
- package/dist/m/plugins/page-WOCNX2IN.js +2 -0
- package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-SJODOOGE.js} +1 -1
- package/dist/m/plugins/{people-VBDR5Y22.js → people-C4TCWFOQ.js} +1 -1
- package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-Y2NV44NA.js} +3 -3
- package/dist/m/plugins/sortable-XCHQG65Z.js +1 -0
- package/dist/m/plugins/{source-ZXDD7F6X.js → source-YK7MO23W.js} +1 -1
- package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-VLRBQNPN.js} +1 -1
- package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-NNTJBSK2.js} +1 -1
- package/dist/m/plugins/{upload-DNJNW6H3.js → upload-ZKKJV76X.js} +1 -1
- package/dist/m/plugins/visitor-drafts-RTZNONHT.js +20 -0
- package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-LHUHFU3L.js} +1 -1
- package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-FY4KM2SW.js} +1 -1
- package/dist/m/plugins/wire-M2NR354P.js +1 -0
- package/dist/m/{reader-core-F6RY3V64.js → reader-core-T2S3JC4E.js} +2 -2
- package/dist/m/sync/{live-sync-M2PP2IWY.js → live-sync-Z64YREWQ.js} +1 -1
- package/dist/m/vendor/{sap-core.vendor-OA5SKL7Q.js → sap-core.vendor-QAEPMETX.js} +1 -1
- package/dist/offline-manifest.json +455 -425
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +1 -1
- package/package.json +2 -2
- package/src/core/draft-mode.js +6 -0
- package/src/core/save-core.js +7 -8
- package/src/core/unsaved-warning.js +2 -2
- package/src/lib/idb-error-code.js +20 -0
- package/src/loader.js +2 -0
- package/src/plugins/page.js +19 -5
- package/src/plugins/sortable.js +7 -0
- package/src/plugins/visitor-drafts/commit.js +303 -0
- package/src/plugins/visitor-drafts/idb.js +221 -0
- package/src/plugins/visitor-drafts/prompt.js +23 -3
- package/src/plugins/visitor-drafts/session.js +345 -0
- package/src/plugins/visitor-drafts/store.js +210 -26
- package/src/plugins/visitor-drafts.js +148 -28
- package/src/vendor/hyper-html-api-data.vendor.js +1 -1
- package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
- package/src/vendor/hypercms.vendor.js +16 -16
- package/src/vendor/richclay.vendor.js +1 -1
- package/src/vendor/sap-core.vendor.js +1 -1
- package/dist/m/c-234RSEIW.js +0 -1
- package/dist/m/c-2YIJ6ZA6.js +0 -1
- package/dist/m/c-H2H7TI6O.js +0 -1
- package/dist/m/c-JMDKPBMC.js +0 -4
- package/dist/m/c-N7JPUPGC.js +0 -1
- package/dist/m/c-NEXFVJIT.js +0 -1
- package/dist/m/c-T527QHOB.js +0 -1
- package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
- package/dist/m/loader-5IA5OJ3J.js +0 -1
- package/dist/m/manifest-98258645.js +0 -1
- package/dist/m/manifest-edit-ABF56000.js +0 -1
- package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
- package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
- package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
- package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
- /package/dist/m/plugins/{richclay-CBLZRFB4.js.LEGAL.txt → richclay-Y2NV44NA.js.LEGAL.txt} +0 -0
|
@@ -4,6 +4,18 @@ import { draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
|
|
|
4
4
|
export const KEY_PREFIX = "clay:visitor-drafts:";
|
|
5
5
|
export const RECORD_VERSION = 1;
|
|
6
6
|
const LINEAGE = 32;
|
|
7
|
+
// What one tab's journal slot in `localStorage` may hold, and what its unconfirmed
|
|
8
|
+
// operations may hold in memory, in characters of JSON.
|
|
9
|
+
export const SLOT_LIMIT = 512 * 1024;
|
|
10
|
+
export const PENDING_LIMIT = 16 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export const newRecordId = () => Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
|
13
|
+
|
|
14
|
+
function textMark(text) {
|
|
15
|
+
let hash = 0x811c9dc5;
|
|
16
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
17
|
+
return (hash >>> 0).toString(36) + "." + text.length.toString(36);
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
// The document a pathname names, spelled the way the hosts resolve it: the path up to
|
|
9
21
|
// and including the first segment that ends in .html or .htmlclay (anything after it is
|
|
@@ -55,7 +67,7 @@ export function fitsInto(wanted, got) {
|
|
|
55
67
|
return wanted === got;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
function same(a, b) {
|
|
70
|
+
export function same(a, b) {
|
|
59
71
|
return fitsInto(a, b) && fitsInto(b, a);
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -78,6 +90,9 @@ function readUnit(source) {
|
|
|
78
90
|
|
|
79
91
|
const sameUnit = (a, b) => (!a || !b ? a === b : a.blockeditor.rev === b.blockeditor.rev && canonical(a.live) === canonical(b.live));
|
|
80
92
|
|
|
93
|
+
// One page content, one mark: two pages `sameUnit` calls the same carry the same one.
|
|
94
|
+
export const unitMark = (unit) => (isPlain(unit?.live) && isPlain(unit?.blockeditor) ? `${unit.blockeditor.rev}:${textMark(canonical(unit.live))}` : null);
|
|
95
|
+
|
|
81
96
|
function unitOf(record) {
|
|
82
97
|
if (!isPlain(record?.live) || !isPlain(record?.blockeditor)) return null;
|
|
83
98
|
const { live, blockeditor } = record;
|
|
@@ -189,7 +204,7 @@ export function merge3(base, mine, theirs) {
|
|
|
189
204
|
return merged;
|
|
190
205
|
}
|
|
191
206
|
|
|
192
|
-
function storageFailure(doc, error) {
|
|
207
|
+
export function storageFailure(doc, error) {
|
|
193
208
|
setDraftStoreFailed(true);
|
|
194
209
|
doc.dispatchEvent(new CustomEvent("clay:drafts-store-failed", { detail: { error } }));
|
|
195
210
|
const failure = new Error("Couldn't save in this browser. Storage is full or turned off.");
|
|
@@ -254,13 +269,20 @@ function withoutSiteKey(value) {
|
|
|
254
269
|
return rest;
|
|
255
270
|
}
|
|
256
271
|
|
|
257
|
-
export function createStore({ doc = document, storage = safeStorage(), now = () => new Date(), path = draftPath(), fallback = null } = {}) {
|
|
272
|
+
export function createStore({ doc = document, storage = safeStorage(), now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
|
|
258
273
|
const slot = draftSlot(doc);
|
|
259
274
|
const key = draftKey(slot, path);
|
|
260
275
|
const rules = withoutSiteKey(engine.findRulesIn(doc, "api").rules);
|
|
261
276
|
|
|
262
277
|
let lastRaw = null;
|
|
263
278
|
let base = null;
|
|
279
|
+
// The served page's data, read before any restore, and the stored data this tab's next
|
|
280
|
+
// operation is built on: the record it restored or last took in with its own operations
|
|
281
|
+
// laid over, or the served page's while nothing is stored.
|
|
282
|
+
let seed = null;
|
|
283
|
+
let tip = null;
|
|
284
|
+
// Nothing of a draft is on this page yet: no restore, no operation, nothing taken in.
|
|
285
|
+
let fresh = true;
|
|
264
286
|
let unparked = null;
|
|
265
287
|
let stopped = false;
|
|
266
288
|
// This tab's own last write: its record id, its ancestry, and the data of the record it
|
|
@@ -317,7 +339,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
317
339
|
return true;
|
|
318
340
|
}
|
|
319
341
|
|
|
320
|
-
const newId =
|
|
342
|
+
const newId = newRecordId;
|
|
321
343
|
|
|
322
344
|
// A record's ancestry is the ids it descends from, nearest first, ending at the empty
|
|
323
345
|
// slot it grew from, or at a record from a runtime that kept no ids, named by its bytes.
|
|
@@ -328,11 +350,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
328
350
|
try { return "root:" + (storage?.getItem(resetKey) ?? ""); } catch { return "root:"; }
|
|
329
351
|
}
|
|
330
352
|
|
|
331
|
-
|
|
332
|
-
let hash = 0x811c9dc5;
|
|
333
|
-
for (let i = 0; i < raw.length; i++) hash = Math.imul(hash ^ raw.charCodeAt(i), 0x01000193);
|
|
334
|
-
return "legacy:" + (hash >>> 0).toString(36) + "." + raw.length.toString(36);
|
|
335
|
-
}
|
|
353
|
+
const legacyId = (raw) => "legacy:" + textMark(raw);
|
|
336
354
|
|
|
337
355
|
function lineageOf(record, raw) {
|
|
338
356
|
if (!record) return null;
|
|
@@ -456,31 +474,37 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
456
474
|
return null;
|
|
457
475
|
}
|
|
458
476
|
|
|
459
|
-
function parkedResult(raw) {
|
|
477
|
+
function parkedResult({ raw, record }) {
|
|
460
478
|
const parkedKey = park(raw);
|
|
461
479
|
if (!parkedKey) lastRaw = raw;
|
|
462
480
|
base = live();
|
|
481
|
+
tip = record?.data ?? base;
|
|
463
482
|
return parkedKey
|
|
464
483
|
? { restored: false, hadDraft: true, parked: true }
|
|
465
484
|
: { restored: false, hadDraft: true, parked: false, blocked: true };
|
|
466
485
|
}
|
|
467
486
|
|
|
468
|
-
|
|
469
|
-
|
|
487
|
+
const seedData = () => (seed ??= live());
|
|
488
|
+
|
|
489
|
+
// `found` is the record to put on the page: the one `localStorage` holds, or the
|
|
490
|
+
// database's when the page waited for it (null: it holds none).
|
|
491
|
+
function restore(found = read()) {
|
|
492
|
+
seedData();
|
|
470
493
|
if (!found) {
|
|
471
|
-
base =
|
|
494
|
+
base = tip = seed;
|
|
472
495
|
whenArticle(readServed);
|
|
473
496
|
return { restored: false, hadDraft: false };
|
|
474
497
|
}
|
|
475
|
-
|
|
498
|
+
fresh = false;
|
|
499
|
+
if (!found.record) return parkedResult(found);
|
|
476
500
|
const unit = unitOf(found.record);
|
|
477
501
|
const hasData = Object.keys(found.record.data).length > 0;
|
|
478
502
|
const fitted = hasData ? fit(found.record.data) : { data: {}, dropped: [] };
|
|
479
503
|
const dataFits = Boolean(fitted) && (!hasData || Object.keys(fitted.data).length > 0);
|
|
480
504
|
// The page does not depend on the rules: a page is placed even when no saved field fits,
|
|
481
505
|
// and the whole record is kept aside as well.
|
|
482
|
-
if (!unit && !dataFits) return parkedResult(found
|
|
483
|
-
if (!unit && !hasData) return parkedResult(found
|
|
506
|
+
if (!unit && !dataFits) return parkedResult(found);
|
|
507
|
+
if (!unit && !hasData) return parkedResult(found);
|
|
484
508
|
if (hasData && dataFits) applyData(doc, fitted.data, rules);
|
|
485
509
|
if (unit) {
|
|
486
510
|
whenArticle(() => {
|
|
@@ -493,6 +517,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
493
517
|
}
|
|
494
518
|
lastRaw = found.raw;
|
|
495
519
|
base = live();
|
|
520
|
+
tip = found.record.data;
|
|
496
521
|
if (!dataFits || fitted.dropped.length) {
|
|
497
522
|
const parkedKey = park(found.raw, { keepSlot: true });
|
|
498
523
|
return { restored: true, hadDraft: true, data: dataFits ? fitted.data : {}, partial: true, parked: Boolean(parkedKey), blocked: !parkedKey };
|
|
@@ -552,6 +577,133 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
552
577
|
return fitted.data;
|
|
553
578
|
}
|
|
554
579
|
|
|
580
|
+
// With the database as the record, a save is an operation: for each key the page changed,
|
|
581
|
+
// the stored value it was built on (`b`, absent when nothing was) and the value now (`m`).
|
|
582
|
+
// `tab.ops` are the ones the database has not confirmed, oldest first, under the Start
|
|
583
|
+
// over marker this page booted on; `sizes` runs beside them. A copy that fits goes into
|
|
584
|
+
// this tab's journal slot at once, so closing the tab loses nothing: `journaled` is the
|
|
585
|
+
// last sequence number the slot holds.
|
|
586
|
+
const tab = { id: newId(), reset: "", acked: 0, seq: 0, journaled: 0, ops: [], sizes: [] };
|
|
587
|
+
try { tab.reset = storage?.getItem(resetKey) ?? ""; } catch {}
|
|
588
|
+
try {
|
|
589
|
+
const known = JSON.parse(window.sessionStorage.getItem(key + ":generation"));
|
|
590
|
+
if (known?.marker === tab.reset && typeof known.reset === "string") tab.reset = known.reset;
|
|
591
|
+
} catch {}
|
|
592
|
+
const slotKey = `${key}:op:${tab.id}`;
|
|
593
|
+
const chain = () => ({ tab: tab.id, reset: tab.reset, acked: tab.acked, at: now().getTime(), ops: tab.ops });
|
|
594
|
+
|
|
595
|
+
function journal() {
|
|
596
|
+
try {
|
|
597
|
+
if (!tab.ops.length) {
|
|
598
|
+
tab.journaled = tab.seq;
|
|
599
|
+
storage.removeItem(slotKey);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const text = JSON.stringify(chain());
|
|
603
|
+
if (text.length > slotLimit) return;
|
|
604
|
+
storage.setItem(slotKey, text);
|
|
605
|
+
tab.journaled = tab.seq;
|
|
606
|
+
} catch {}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// What the page held before its unconfirmed operations, for a page load that falls back
|
|
610
|
+
// to `localStorage` after it made some.
|
|
611
|
+
let before = null;
|
|
612
|
+
|
|
613
|
+
function capture(html) {
|
|
614
|
+
if (stopped) return null;
|
|
615
|
+
if (unparked != null && park(unparked, { keepSlot: true }) === null) {
|
|
616
|
+
throw storageFailure(doc, new Error("an earlier saved copy could not be kept aside"));
|
|
617
|
+
}
|
|
618
|
+
// Too much is waiting: nothing more is taken until it drains, and the page keeps it.
|
|
619
|
+
if (tab.sizes.reduce((sum, size) => sum + size, 0) >= pendingLimit) throw storageFailure(doc, new Error("too many changes are waiting to be saved"));
|
|
620
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
621
|
+
const mine = extractData(parsed, rules);
|
|
622
|
+
const mineUnit = recordable(readUnit(parsed));
|
|
623
|
+
const unitChanged = !sameUnit(mineUnit, editorUnit);
|
|
624
|
+
const keys = {};
|
|
625
|
+
for (const name of Object.keys(mine)) {
|
|
626
|
+
if (name in base && fitsInto(mine[name], base[name])) continue;
|
|
627
|
+
// Fields the stored value has and this page's rules lack are carried through.
|
|
628
|
+
keys[name] = name in tip ? { b: tip[name], m: complete(mine[name], tip[name]) } : { m: mine[name] };
|
|
629
|
+
}
|
|
630
|
+
if (!Object.keys(keys).length && !unitChanged) {
|
|
631
|
+
if (!tab.ops.length) {
|
|
632
|
+
setDraftStoreFailed(false);
|
|
633
|
+
doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data: mine, unchanged: true } }));
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
if (!tab.ops.length) before = { base, editorUnit, seenUnit };
|
|
638
|
+
const op = { seq: ++tab.seq, savedAt: now().toISOString(), keys, ...(unitChanged && { unit: { seen: unitMark(seenUnit), to: mineUnit } }) };
|
|
639
|
+
base = { ...base };
|
|
640
|
+
tip = { ...tip };
|
|
641
|
+
for (const name of Object.keys(keys)) {
|
|
642
|
+
base[name] = mine[name];
|
|
643
|
+
tip[name] = keys[name].m;
|
|
644
|
+
}
|
|
645
|
+
if (unitChanged) editorUnit = seenUnit = mineUnit;
|
|
646
|
+
fresh = false;
|
|
647
|
+
tab.ops.push(op);
|
|
648
|
+
tab.sizes.push(JSON.stringify(op).length);
|
|
649
|
+
journal();
|
|
650
|
+
return op;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// A hide's synchronous half of a save. False while an operation is neither in the
|
|
654
|
+
// journal slot nor confirmed: closing the tab now could lose it.
|
|
655
|
+
function keep() {
|
|
656
|
+
if (stopped) return true;
|
|
657
|
+
try {
|
|
658
|
+
if (changed()) capture(window.clay.getHTML());
|
|
659
|
+
} catch { return false; }
|
|
660
|
+
return tab.journaled === tab.seq;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function acknowledge(seq) {
|
|
664
|
+
if (seq <= tab.acked) return false;
|
|
665
|
+
tab.acked = seq;
|
|
666
|
+
const done = tab.ops.filter((op) => op.seq <= seq).length;
|
|
667
|
+
tab.ops.splice(0, done);
|
|
668
|
+
tab.sizes.splice(0, done);
|
|
669
|
+
journal();
|
|
670
|
+
return done > 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function drop() {
|
|
674
|
+
tab.ops.length = 0;
|
|
675
|
+
tab.sizes.length = 0;
|
|
676
|
+
journal();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The database did not open: what the journal holds goes back to being this page's
|
|
680
|
+
// unsaved changes, for `write` to save.
|
|
681
|
+
function release() {
|
|
682
|
+
if (tab.ops.length) ({ base, editorUnit, seenUnit } = before);
|
|
683
|
+
drop();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A page with nothing unsaved takes in what the database holds: the keys whose stored
|
|
687
|
+
// value is no longer the one this tab was built on.
|
|
688
|
+
function take(data) {
|
|
689
|
+
const moved = Object.fromEntries(Object.entries(data).filter(([name, value]) => !same(value, tip[name])));
|
|
690
|
+
if (!Object.keys(moved).length) return false;
|
|
691
|
+
const shown = adopt(moved);
|
|
692
|
+
tip = { ...tip, ...moved };
|
|
693
|
+
if (shown) base = { ...base, ...shown };
|
|
694
|
+
fresh = false;
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The generation this page shows has ended, by this tab's Start over or another tab's:
|
|
699
|
+
// nothing more is saved from it.
|
|
700
|
+
function stop({ keepPending = false } = {}) {
|
|
701
|
+
stopped = true;
|
|
702
|
+
if (!keepPending) drop();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const journalSlots = () => storedKeys().filter((name) => name.startsWith(key + ":op:"));
|
|
706
|
+
|
|
555
707
|
function write(html) {
|
|
556
708
|
if (stopped) return null;
|
|
557
709
|
if (unparked != null && park(unparked, { keepSlot: true }) === null) {
|
|
@@ -578,7 +730,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
578
730
|
const id = newId();
|
|
579
731
|
const raw = stored && sameUnit(unit, theirs)
|
|
580
732
|
? stored
|
|
581
|
-
: JSON.stringify({ v: RECORD_VERSION, savedAt: now().toISOString(), id, ancestry, data, ...(unit && { live: unit.live, blockeditor: unit.blockeditor }) });
|
|
733
|
+
: JSON.stringify({ v: RECORD_VERSION, savedAt: now().toISOString(), id, ancestry, reset: tab.reset, data, ...(unit && { live: unit.live, blockeditor: unit.blockeditor }) });
|
|
582
734
|
if (raw !== stored) {
|
|
583
735
|
try {
|
|
584
736
|
if (!storage) throw new Error("no storage");
|
|
@@ -634,11 +786,15 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
634
786
|
return true;
|
|
635
787
|
}
|
|
636
788
|
|
|
637
|
-
// The page another tab recorded since this tab last read or wrote one, or null.
|
|
638
|
-
|
|
789
|
+
// The page another tab recorded since this tab last read or wrote one, or null. `record`
|
|
790
|
+
// is the database's; without one the copy in `localStorage` is read.
|
|
791
|
+
function externalUnit(record) {
|
|
639
792
|
if (stopped) return null;
|
|
640
|
-
|
|
641
|
-
|
|
793
|
+
if (record === undefined) {
|
|
794
|
+
const raw = getRaw();
|
|
795
|
+
record = raw == null ? null : parse(raw);
|
|
796
|
+
}
|
|
797
|
+
const theirs = unitOf(record);
|
|
642
798
|
return theirs && !sameUnit(theirs, seenUnit) ? theirs : null;
|
|
643
799
|
}
|
|
644
800
|
|
|
@@ -648,12 +804,40 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
648
804
|
}
|
|
649
805
|
|
|
650
806
|
function clear() {
|
|
651
|
-
|
|
807
|
+
stop();
|
|
652
808
|
unparked = null;
|
|
653
809
|
setDraftStoreFailed(false);
|
|
654
810
|
try { storage.removeItem(key); } catch {}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
811
|
+
const match = /^(\d+)~/.exec(tab.reset);
|
|
812
|
+
const gen = match ? Number(match[1]) : tab.reset === "" ? 1 : null;
|
|
813
|
+
try { storage.setItem(resetKey, gen === null ? newId() : `${gen + 1}~${newId()}`); } catch {}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
slot, key, rules, read, restore, write, writeLive, flush, changed, adoptExternal, externalUnit, adoptedUnit, park, clear, snapshot,
|
|
818
|
+
tab: tab.id, slotKey, chain, capture, keep, acknowledge, release, take, stop, journalSlots,
|
|
819
|
+
seed: seedData,
|
|
820
|
+
// For the session, which is fetched after the boot and imports nothing from here.
|
|
821
|
+
merge3,
|
|
822
|
+
unitMark,
|
|
823
|
+
newId,
|
|
824
|
+
failed: (error) => storageFailure(doc, error),
|
|
825
|
+
generation: () => tab.reset,
|
|
826
|
+
enter: (reset) => { tab.reset = reset; },
|
|
827
|
+
fresh: () => fresh,
|
|
828
|
+
shown: () => base !== null,
|
|
829
|
+
pending: () => tab.ops.length,
|
|
830
|
+
unsafe: () => tab.journaled < tab.seq,
|
|
831
|
+
stopped: () => stopped,
|
|
832
|
+
// What `localStorage` holds at the draft's key, whatever wrote it: a record, the head of
|
|
833
|
+
// a draft whose full record is only in the database (`idb`), or nothing readable.
|
|
834
|
+
held() {
|
|
835
|
+
try {
|
|
836
|
+
const held = JSON.parse(getRaw());
|
|
837
|
+
return isPlain(held) ? held : null;
|
|
838
|
+
} catch { return null; }
|
|
839
|
+
},
|
|
840
|
+
// A draft is kept when `localStorage` holds its copy or head, or any tab's journal.
|
|
841
|
+
kept: () => Boolean(read()) || tab.ops.length > 0 || journalSlots().length > 0,
|
|
842
|
+
};
|
|
659
843
|
}
|
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
import { isDraftMode, isEditMode, isOwner } from "../core/is-edit-mode.js";
|
|
2
2
|
import { hasSaveToken } from "../core/host-attrs.js";
|
|
3
|
-
import { draftSlot, setDraftTransport, setDraftFlush } from "../core/draft-mode.js";
|
|
4
|
-
import {
|
|
3
|
+
import { draftSlot, draftStoreFailed, setDraftTransport, setDraftFlush, setDraftUnconfirmed } from "../core/draft-mode.js";
|
|
4
|
+
import { reloadAfterDiscard } from "../core/unsaved-warning.js";
|
|
5
|
+
import { createStore, adoptLegacy, draftPath, legacyKey, safeStorage, storageFailure } from "./visitor-drafts/store.js";
|
|
5
6
|
import { createUiMemory } from "./visitor-drafts/ui-memory.js";
|
|
6
7
|
import { rememberSource, downloadUrl } from "./visitor-drafts/source.js";
|
|
7
8
|
import { mountPrompt } from "./visitor-drafts/prompt.js";
|
|
8
9
|
import { DOWNLOAD_URL, backToDraft, editOriginal, inertVisitorDrafts } from "./visitor-drafts/inert.js";
|
|
9
10
|
|
|
10
11
|
let store = null;
|
|
12
|
+
// This tab's side of the draft database, once it has opened. Without one, `localStorage`
|
|
13
|
+
// holds the record for this page load, as it did before the database.
|
|
14
|
+
let session = null;
|
|
15
|
+
let direct = true;
|
|
16
|
+
let database = false;
|
|
17
|
+
let opening = Promise.resolve();
|
|
18
|
+
// The database did not open and the full draft is only there: nothing is saved over it
|
|
19
|
+
// for the rest of this page load.
|
|
20
|
+
let refused = false;
|
|
11
21
|
let restoredDetail = null;
|
|
12
22
|
let downloadLink = DOWNLOAD_URL;
|
|
13
23
|
let source = null;
|
|
14
24
|
let ready = Promise.resolve();
|
|
25
|
+
// Settles once the draft is on the page. Already settled for a first visit and for a
|
|
26
|
+
// draft `localStorage` holds in full; the loader awaits it before the next plugin loads.
|
|
27
|
+
let restoring = Promise.resolve();
|
|
28
|
+
// The synchronous half of a save: false while something this tab changed is neither
|
|
29
|
+
// stored nor in its journal slot.
|
|
30
|
+
let keep = () => true;
|
|
31
|
+
// A promise while the page's last changes are not yet where the download page reads them,
|
|
32
|
+
// null once they are.
|
|
33
|
+
let settle = () => null;
|
|
34
|
+
// Past this deadline the download uses this page's current HTML.
|
|
35
|
+
const DOWNLOAD_WAIT_MS = 3000;
|
|
15
36
|
|
|
16
37
|
// The chip is a convenience: a failed import leaves the draft working without it.
|
|
17
38
|
function mountChip() {
|
|
@@ -41,28 +62,82 @@ const rulesTag = document.querySelector('script[data-rules-name~="api"]');
|
|
|
41
62
|
if (isDraftMode && slot && rulesTag) {
|
|
42
63
|
const path = draftPath();
|
|
43
64
|
const moved = adoptLegacy(slot, { path });
|
|
65
|
+
const local = safeStorage();
|
|
44
66
|
store = createStore({ path, fallback: moved === "failed" ? legacyKey(slot) : null });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
direct = !(typeof globalThis.indexedDB?.open === "function" && globalThis.IDBKeyRange);
|
|
68
|
+
database = !direct;
|
|
69
|
+
// The session loads beside the database open, off the path of a draft `localStorage` holds
|
|
70
|
+
// in full. Until it is there a save still makes its operation and journals it.
|
|
71
|
+
opening = direct ? Promise.resolve() : import("./visitor-drafts/session.js")
|
|
72
|
+
.then((m) => m.openSession({ store, storage: local, reload: () => reloadAfterDiscard({ isCurrent: () => true, reload: () => window.location.reload() }) }))
|
|
73
|
+
.then((opened) => { session = opened; }, () => { direct = true; });
|
|
74
|
+
|
|
75
|
+
setDraftTransport((html) => {
|
|
76
|
+
const recapture = !restoredDetail;
|
|
77
|
+
return restoring.then(() => {
|
|
78
|
+
if (refused) throw storageFailure(document, new Error("the draft database did not open"));
|
|
79
|
+
if (recapture) html = window.clay.getHTML();
|
|
80
|
+
if (direct) return void store.write(html);
|
|
81
|
+
store.capture(html);
|
|
82
|
+
return opening.then(() => {
|
|
83
|
+
if (session) return session.sync().then(() => {});
|
|
84
|
+
store.write(html);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const memory = createUiMemory(store.key, local);
|
|
50
90
|
source = rememberSource(memory);
|
|
51
91
|
downloadLink = downloadUrl(source);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
|
|
93
|
+
function finish(result) {
|
|
94
|
+
restoredDetail = { restored: result.restored, hadDraft: result.hadDraft, parked: Boolean(result.parked), ...(result.partial && { partial: true }), ...(result.blocked && { blocked: true }) };
|
|
95
|
+
document.dispatchEvent(new CustomEvent("clay:drafts-restored", { detail: restoredDetail }));
|
|
96
|
+
const appName = document.querySelector('meta[name="application-name"]')?.content?.trim() || document.title.split(/:|\s\|\s/)[0].trim() || "This app";
|
|
97
|
+
const template = document.querySelector("template[visitor-intro]");
|
|
98
|
+
const start = () => {
|
|
99
|
+
ready = mountPrompt({ memory, appName, link: downloadLink, settle: () => settle(), canDownloadCurrent: () => !refused && !restoredDetail.blocked }).then((prompt) => mountIntroLate({ memory, template, appName, hadDraft: result.hadDraft || store.kept() }).then(() => prompt)).then((prompt) => {
|
|
100
|
+
document.addEventListener("clay:drafts-store-failed", () => prompt.showError());
|
|
101
|
+
document.addEventListener("clay:drafts-saved", () => prompt.clearError());
|
|
102
|
+
if (draftStoreFailed()) prompt.showError();
|
|
103
|
+
return (isOwner ? mountChip() : Promise.resolve(null)).then(() => prompt);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", start, { once: true }) : start();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
keep = () => {
|
|
110
|
+
if (refused) return false;
|
|
111
|
+
if (!restoredDetail) return true;
|
|
112
|
+
if (direct) return store.flush();
|
|
113
|
+
const safe = store.keep();
|
|
114
|
+
session?.hide();
|
|
115
|
+
return safe;
|
|
116
|
+
};
|
|
117
|
+
setDraftFlush(keep);
|
|
118
|
+
setDraftUnconfirmed(() => !direct && store.unsafe());
|
|
119
|
+
settle = () => {
|
|
120
|
+
if (refused) return Promise.resolve(false);
|
|
121
|
+
if (!restoredDetail) return restoring.then(() => settle());
|
|
122
|
+
if (direct) {
|
|
123
|
+
return store.flush() ? null : Promise.resolve(false);
|
|
124
|
+
}
|
|
125
|
+
const safe = store.keep();
|
|
126
|
+
if (!store.pending()) return safe ? null : Promise.resolve(false);
|
|
127
|
+
const committed = opening.then(() => session ? session.sync().then(() => true) : store.flush()).catch(() => false);
|
|
128
|
+
let timer;
|
|
129
|
+
return Promise.race([committed, new Promise((resolve) => { timer = setTimeout(() => resolve(false), DOWNLOAD_WAIT_MS); })])
|
|
130
|
+
.finally(() => clearTimeout(timer));
|
|
60
131
|
};
|
|
61
|
-
document.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
132
|
+
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") keep(); });
|
|
133
|
+
window.addEventListener("pagehide", keep);
|
|
134
|
+
|
|
135
|
+
// Another tab's save reaches a page on the database through its session. With
|
|
136
|
+
// `localStorage` as the record it arrives as that key's storage event, once the page
|
|
137
|
+
// editor listens (an event before that is missed), and when the tab comes back from the
|
|
138
|
+
// page cache.
|
|
65
139
|
const takeOtherTab = () => {
|
|
140
|
+
if (!direct || refused || !restoredDetail) return;
|
|
66
141
|
// Read before adopting: adopting reads the record as this tab's own.
|
|
67
142
|
const unit = store.externalUnit();
|
|
68
143
|
// A page another tab wrote goes to the page editor, which merges it into its own.
|
|
@@ -72,9 +147,36 @@ if (isDraftMode && slot && rulesTag) {
|
|
|
72
147
|
window.addEventListener("storage", (event) => { if (event.key === store.key) takeOtherTab(); });
|
|
73
148
|
document.addEventListener("clay:drafts-blockeditor-listening", takeOtherTab);
|
|
74
149
|
window.addEventListener("pageshow", (event) => { if (event.persisted) takeOtherTab(); });
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
150
|
+
|
|
151
|
+
const held = store.held();
|
|
152
|
+
const head = held?.v === 2 && held.idb === true;
|
|
153
|
+
const foreignReset = !direct && held?.v === 1 && held.w === undefined && store.generation() !== "";
|
|
154
|
+
if (head || foreignReset || (!direct && held?.w && held.w.reset !== store.generation())) {
|
|
155
|
+
// The full draft is only in the database, or this copy is from before the last Start
|
|
156
|
+
// over: the page waits for the database's record, and plugins that read the document
|
|
157
|
+
// wait with the page.
|
|
158
|
+
restoring = opening.then(() => session?.first).then((result) => {
|
|
159
|
+
if (result) return finish(store.restore(result.record && { raw: JSON.stringify(result.record), record: result.record }));
|
|
160
|
+
throw new Error("the draft database did not open");
|
|
161
|
+
}).catch((error) => {
|
|
162
|
+
if (!head) {
|
|
163
|
+
if (!foreignReset) { try { local?.removeItem(store.key); } catch {} }
|
|
164
|
+
return finish(store.restore());
|
|
165
|
+
}
|
|
166
|
+
refused = true;
|
|
167
|
+
storageFailure(document, error);
|
|
168
|
+
finish({ restored: false, hadDraft: true, blocked: true });
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
finish(store.restore());
|
|
172
|
+
// The database did not open: `localStorage` is the record for this page load, and what
|
|
173
|
+
// this tab journaled while it waited is saved there.
|
|
174
|
+
opening.then(() => {
|
|
175
|
+
if (session) return;
|
|
176
|
+
store.release();
|
|
177
|
+
store.flush();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
78
180
|
} else if (!slot || !rulesTag) {
|
|
79
181
|
const missing = [];
|
|
80
182
|
if (!slot) missing.push('a visitor-drafts="<slot>" attribute on <html>');
|
|
@@ -84,18 +186,33 @@ if (isDraftMode && slot && rulesTag) {
|
|
|
84
186
|
ready = mountChip();
|
|
85
187
|
}
|
|
86
188
|
|
|
87
|
-
export { ready };
|
|
189
|
+
export { ready, restoring };
|
|
88
190
|
|
|
89
191
|
export const visitorDrafts = store ? {
|
|
90
192
|
active: true,
|
|
91
193
|
slot: store.slot,
|
|
92
194
|
key: store.key,
|
|
93
|
-
hasDraft: () =>
|
|
195
|
+
hasDraft: () => store.kept(),
|
|
94
196
|
restored: () => restoredDetail,
|
|
95
|
-
saveNow
|
|
197
|
+
saveNow() {
|
|
198
|
+
if (refused || !restoredDetail) return false;
|
|
199
|
+
if (direct) return store.writeLive();
|
|
200
|
+
try { store.capture(window.clay.getHTML()); } catch { return false; }
|
|
201
|
+
session?.sync().catch(() => {});
|
|
202
|
+
return !store.unsafe();
|
|
203
|
+
},
|
|
96
204
|
reset() {
|
|
97
|
-
|
|
98
|
-
|
|
205
|
+
if (!database) {
|
|
206
|
+
store.clear();
|
|
207
|
+
window.location.reload();
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
// The generation ends in the database before the page loads again.
|
|
211
|
+
opening.then(() => {
|
|
212
|
+
if (session) return session.startOver();
|
|
213
|
+
if (direct && !refused) return store.clear();
|
|
214
|
+
throw new Error("the draft database did not open");
|
|
215
|
+
}).then(() => window.location.reload(), (error) => storageFailure(document, error));
|
|
99
216
|
return true;
|
|
100
217
|
},
|
|
101
218
|
onRestore(fn) {
|
|
@@ -109,6 +226,9 @@ export const visitorDrafts = store ? {
|
|
|
109
226
|
editOriginal,
|
|
110
227
|
backToDraft,
|
|
111
228
|
downloadUrl: () => downloadLink,
|
|
112
|
-
download: () => ready.then((prompt) => {
|
|
229
|
+
download: () => ready.then(async (prompt) => {
|
|
230
|
+
const saved = await settle();
|
|
231
|
+
return prompt?.download(saved === false);
|
|
232
|
+
}),
|
|
113
233
|
source,
|
|
114
234
|
} : inertVisitorDrafts();
|