@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.
Files changed (136) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +15016 -13603
  11. package/dist/m/c-277QP5ET.js +1 -0
  12. package/dist/m/{c-BULK5J4M.js → c-3UW5EJ77.js} +1 -1
  13. package/dist/m/{c-GM7OA6EJ.js → c-42ZNV7LI.js} +1 -1
  14. package/dist/m/{c-RXTPKCMC.js → c-6IJEVE6B.js} +1 -1
  15. package/dist/m/{c-2R4OBWIP.js → c-6LEYVQJK.js} +1 -1
  16. package/dist/m/{c-LO4C4J77.js → c-6Q6RGFBF.js} +1 -1
  17. package/dist/m/{c-PD5AKHY3.js → c-7CM4GMTS.js} +1 -1
  18. package/dist/m/c-APGHGDZM.js +1 -0
  19. package/dist/m/{c-Q6VNQDJE.js → c-B7GOFGQR.js} +1 -1
  20. package/dist/m/{c-RJKKRWHW.js → c-BGN7RCTJ.js} +1 -1
  21. package/dist/m/{c-A7N5TNSL.js → c-C5AKJVRH.js} +1 -1
  22. package/dist/m/{c-SNVDWMM3.js → c-CJ7QYEWI.js} +1 -1
  23. package/dist/m/{c-PVJMJL3P.js → c-CZHTXGJS.js} +1 -1
  24. package/dist/m/{c-KJHVB2KG.js → c-DDS23EY6.js} +1 -1
  25. package/dist/m/{c-VSEUFJAO.js → c-DFKBGSN2.js} +1 -1
  26. package/dist/m/{c-DLBX4JCJ.js → c-EDE45NM7.js} +1 -1
  27. package/dist/m/c-EILLN2H2.js +1 -0
  28. package/dist/m/{c-EY5KDUZF.js → c-FEC6LGFF.js} +1 -1
  29. package/dist/m/{c-PMRRMT72.js → c-HDVH3MVC.js} +1 -1
  30. package/dist/m/c-HKSLOVU4.js +1 -0
  31. package/dist/m/{c-GNIVHLEJ.js → c-HL6BLJNA.js} +1 -1
  32. package/dist/m/c-HTLYXCUH.js +1 -0
  33. package/dist/m/{c-RTSRGGTP.js → c-JQF2EUCG.js} +1 -1
  34. package/dist/m/{c-O5ARALMJ.js → c-JWDCQL6J.js} +1 -1
  35. package/dist/m/{c-WOELSNWS.js → c-LATNQ6Q5.js} +1 -1
  36. package/dist/m/{c-UM7VZDTW.js → c-LSVN3OLG.js} +1 -1
  37. package/dist/m/{c-HSNGLIFB.js → c-MFOSMAVM.js} +2 -2
  38. package/dist/m/{c-FZRZPIFS.js → c-MQYEDF3Q.js} +1 -1
  39. package/dist/m/{c-2OIKXOY6.js → c-ND3Q2G2I.js} +1 -1
  40. package/dist/m/{c-PZRWZZRO.js → c-OCYG4NBQ.js} +1 -1
  41. package/dist/m/{c-54ZZZYUN.js → c-P2HKMJAW.js} +1 -1
  42. package/dist/m/{c-VI3BJHCM.js → c-PCNJETUA.js} +1 -1
  43. package/dist/m/{c-7YETSAVY.js → c-PDBNCJCT.js} +1 -1
  44. package/dist/m/{c-A5ZBFICF.js → c-PYG2NGWU.js} +1 -1
  45. package/dist/m/{c-RBPZNU2D.js → c-QQFHEODI.js} +1 -1
  46. package/dist/m/{c-YINHPF4V.js → c-SGH3UTF3.js} +1 -1
  47. package/dist/m/{c-FV46ZKGX.js → c-SKMIR5HQ.js} +1 -1
  48. package/dist/m/{c-TWOXQQKV.js → c-STRI73GQ.js} +1 -1
  49. package/dist/m/{c-75N3KG35.js → c-T3P3AXEW.js} +1 -1
  50. package/dist/m/c-U4NTUVDZ.js +1 -0
  51. package/dist/m/{c-P5NKFRL4.js → c-UOOIF2AL.js} +1 -1
  52. package/dist/m/c-VHSMPITY.js +1 -0
  53. package/dist/m/{c-6UPEKW6O.js → c-X75G43SZ.js} +1 -1
  54. package/dist/m/{c-YGGO2G2L.js → c-XW5TIYYB.js} +1 -1
  55. package/dist/m/{c-DZPO22O3.js → c-YDW4VCHP.js} +1 -1
  56. package/dist/m/{c-YB3CDTDZ.js → c-YLHTD3KX.js} +1 -1
  57. package/dist/m/c-Z4G2UK6T.js +4 -0
  58. package/dist/m/{c-VNAYHV2J.js → c-ZGCO4J3H.js} +1 -1
  59. package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ONRWVXPK.js} +1 -1
  60. package/dist/m/core/{autosave-4P66VXX7.js → autosave-TBKAAAFB.js} +1 -1
  61. package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-FOEE6I7E.js} +1 -1
  62. package/dist/m/core/draft-mode-4MCL3LYI.js +1 -0
  63. package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-X4BENZEU.js} +1 -1
  64. package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-FFHCQ5DR.js} +1 -1
  65. package/dist/m/core/{persist-AIJGTKKI.js → persist-6ILUR34S.js} +1 -1
  66. package/dist/m/core/{recovery-LPB6X72P.js → recovery-APWADU4K.js} +1 -1
  67. package/dist/m/core/{save-FV4ZCKBI.js → save-6HT2LABE.js} +1 -1
  68. package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-EVMNZQ4V.js} +1 -1
  69. package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-G4BOZMV4.js} +1 -1
  70. package/dist/m/core/{unsaved-warning-FF63QGKI.js → unsaved-warning-ITZDIZ2I.js} +1 -1
  71. package/dist/m/internals/{index-AX4CG7B2.js → index-HS3CQDFW.js} +1 -1
  72. package/dist/m/loader-GLLVDUZA.js +1 -0
  73. package/dist/m/manifest-50FF1C45.js +1 -0
  74. package/dist/m/manifest-edit-BE1A621D.js +1 -0
  75. package/dist/m/manifest.json +710 -674
  76. package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-LABECCSN.js} +1 -1
  77. package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
  78. package/dist/m/plugins/{demo-JIR7DXJA.js → demo-T7B4RIKL.js} +1 -1
  79. package/dist/m/plugins/{derive-QVI3LI3A.js → derive-6LPJOCAV.js} +1 -1
  80. package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-J4ABFARX.js} +1 -1
  81. package/dist/m/plugins/{live-UAJGWRNP.js → live-PGBIEMGB.js} +1 -1
  82. package/dist/m/plugins/{menu-BFJ644YX.js → menu-YZL3X2AJ.js} +1 -1
  83. package/dist/m/plugins/page-WOCNX2IN.js +2 -0
  84. package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-SJODOOGE.js} +1 -1
  85. package/dist/m/plugins/{people-VBDR5Y22.js → people-C4TCWFOQ.js} +1 -1
  86. package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-Y2NV44NA.js} +3 -3
  87. package/dist/m/plugins/sortable-XCHQG65Z.js +1 -0
  88. package/dist/m/plugins/{source-ZXDD7F6X.js → source-YK7MO23W.js} +1 -1
  89. package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-VLRBQNPN.js} +1 -1
  90. package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-NNTJBSK2.js} +1 -1
  91. package/dist/m/plugins/{upload-DNJNW6H3.js → upload-ZKKJV76X.js} +1 -1
  92. package/dist/m/plugins/visitor-drafts-RTZNONHT.js +20 -0
  93. package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-LHUHFU3L.js} +1 -1
  94. package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-FY4KM2SW.js} +1 -1
  95. package/dist/m/plugins/wire-M2NR354P.js +1 -0
  96. package/dist/m/{reader-core-F6RY3V64.js → reader-core-T2S3JC4E.js} +2 -2
  97. package/dist/m/sync/{live-sync-M2PP2IWY.js → live-sync-Z64YREWQ.js} +1 -1
  98. package/dist/m/vendor/{sap-core.vendor-OA5SKL7Q.js → sap-core.vendor-QAEPMETX.js} +1 -1
  99. package/dist/offline-manifest.json +455 -425
  100. package/entries/clay-data.js +1 -1
  101. package/entries/sap.js +1 -1
  102. package/package.json +2 -2
  103. package/src/core/draft-mode.js +6 -0
  104. package/src/core/save-core.js +7 -8
  105. package/src/core/unsaved-warning.js +2 -2
  106. package/src/lib/idb-error-code.js +20 -0
  107. package/src/loader.js +2 -0
  108. package/src/plugins/page.js +19 -5
  109. package/src/plugins/sortable.js +7 -0
  110. package/src/plugins/visitor-drafts/commit.js +303 -0
  111. package/src/plugins/visitor-drafts/idb.js +221 -0
  112. package/src/plugins/visitor-drafts/prompt.js +23 -3
  113. package/src/plugins/visitor-drafts/session.js +345 -0
  114. package/src/plugins/visitor-drafts/store.js +210 -26
  115. package/src/plugins/visitor-drafts.js +148 -28
  116. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  117. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  118. package/src/vendor/hypercms.vendor.js +16 -16
  119. package/src/vendor/richclay.vendor.js +1 -1
  120. package/src/vendor/sap-core.vendor.js +1 -1
  121. package/dist/m/c-234RSEIW.js +0 -1
  122. package/dist/m/c-2YIJ6ZA6.js +0 -1
  123. package/dist/m/c-H2H7TI6O.js +0 -1
  124. package/dist/m/c-JMDKPBMC.js +0 -4
  125. package/dist/m/c-N7JPUPGC.js +0 -1
  126. package/dist/m/c-NEXFVJIT.js +0 -1
  127. package/dist/m/c-T527QHOB.js +0 -1
  128. package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
  129. package/dist/m/loader-5IA5OJ3J.js +0 -1
  130. package/dist/m/manifest-98258645.js +0 -1
  131. package/dist/m/manifest-edit-ABF56000.js +0 -1
  132. package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
  133. package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
  134. package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
  135. package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
  136. /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 = () => Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
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
- function legacyId(raw) {
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
- function restore() {
469
- const found = read();
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 = live();
494
+ base = tip = seed;
472
495
  whenArticle(readServed);
473
496
  return { restored: false, hadDraft: false };
474
497
  }
475
- if (!found.record) return parkedResult(found.raw);
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.raw);
483
- if (!unit && !hasData) return parkedResult(found.raw);
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
- function externalUnit() {
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
- const raw = getRaw();
641
- const theirs = raw == null ? null : unitOf(parse(raw));
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
- stopped = true;
807
+ stop();
652
808
  unparked = null;
653
809
  setDraftStoreFailed(false);
654
810
  try { storage.removeItem(key); } catch {}
655
- try { storage.setItem(resetKey, newId()); } catch {}
656
- }
657
-
658
- return { slot, key, rules, read, restore, write, writeLive, flush, changed, adoptExternal, externalUnit, adoptedUnit, park, clear, snapshot };
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 { createStore, adoptLegacy, draftPath, legacyKey, safeStorage } from "./visitor-drafts/store.js";
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
- setDraftTransport((html) => store.write(html));
46
- const result = store.restore();
47
- restoredDetail = { restored: result.restored, hadDraft: result.hadDraft, parked: Boolean(result.parked), ...(result.partial && { partial: true }), ...(result.blocked && { blocked: true }) };
48
- document.dispatchEvent(new CustomEvent("clay:drafts-restored", { detail: restoredDetail }));
49
- const memory = createUiMemory(store.key, safeStorage());
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
- const appName = document.querySelector('meta[name="application-name"]')?.content?.trim() || document.title.split(/:|\s\|\s/)[0].trim() || "This app";
53
- const template = document.querySelector("template[visitor-intro]");
54
- const start = () => {
55
- ready = mountPrompt({ memory, appName, link: downloadLink }).then((prompt) => mountIntroLate({ memory, template, appName, hadDraft: result.hadDraft }).then(() => prompt)).then((prompt) => {
56
- document.addEventListener("clay:drafts-store-failed", () => prompt.showError());
57
- document.addEventListener("clay:drafts-saved", () => prompt.clearError());
58
- return (isOwner ? mountChip() : Promise.resolve(null)).then(() => prompt);
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.readyState === "loading" ? document.addEventListener("DOMContentLoaded", start, { once: true }) : start();
62
- setDraftFlush(() => store.flush());
63
- // Another tab's page, taken in by the page editor: on its storage event, once the editor
64
- // listens (an event before that is missed), and when the tab comes back from the page cache.
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
- const flush = () => store.flush();
76
- document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flush(); });
77
- window.addEventListener("pagehide", flush);
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: () => Boolean(store.read()),
195
+ hasDraft: () => store.kept(),
94
196
  restored: () => restoredDetail,
95
- saveNow: () => store.writeLive(),
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
- store.clear();
98
- window.location.reload();
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) => { store.flush(); return prompt?.download(); }),
229
+ download: () => ready.then(async (prompt) => {
230
+ const saved = await settle();
231
+ return prompt?.download(saved === false);
232
+ }),
113
233
  source,
114
234
  } : inertVisitorDrafts();