@panphora/clayjs 1.24.0 → 1.25.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.
Files changed (209) 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 +16610 -14400
  11. package/dist/m/attrs/{refetch-on-save-DPGJJMGN.js → refetch-on-save-5CBFUJUF.js} +1 -1
  12. package/dist/m/attrs/save-freeze-MH3AOD56.js +1 -0
  13. package/dist/m/c-277QP5ET.js +1 -0
  14. package/dist/m/{c-SXGEWPFC.js → c-3E2IK5HU.js} +1 -1
  15. package/dist/m/{c-A4L7APIU.js → c-4AE2A2LK.js} +1 -1
  16. package/dist/m/{c-DZPO22O3.js → c-4Q6QIODW.js} +1 -1
  17. package/dist/m/{c-RO73MKKR.js → c-52OC3OH3.js} +1 -1
  18. package/dist/m/{c-VNAYHV2J.js → c-5PZYDWTW.js} +1 -1
  19. package/dist/m/c-5Q5XBN56.js +1 -0
  20. package/dist/m/c-5SZ6KFR5.js +1 -0
  21. package/dist/m/{c-ZRGEN6QQ.js → c-AI3H6ETU.js} +2 -2
  22. package/dist/m/{c-7V6MGMX2.js → c-BCWYEG6P.js} +1 -1
  23. package/dist/m/{c-A7N5TNSL.js → c-BJ2RRQBQ.js} +1 -1
  24. package/dist/m/{c-CTGRDT6E.js → c-C6YZNU6E.js} +1 -1
  25. package/dist/m/{c-DLBX4JCJ.js → c-C7SUMNMI.js} +1 -1
  26. package/dist/m/c-CITLTVQ4.js +4 -0
  27. package/dist/m/{c-TWOXQQKV.js → c-CL5LSATX.js} +1 -1
  28. package/dist/m/c-CU33V6RW.js +4 -0
  29. package/dist/m/{c-2R4OBWIP.js → c-D62HRSZK.js} +1 -1
  30. package/dist/m/{c-DD26VXAP.js → c-DT7E76Z2.js} +1 -1
  31. package/dist/m/{c-54ZZZYUN.js → c-EAV7A6BT.js} +1 -1
  32. package/dist/m/{c-RBPZNU2D.js → c-EQ5NBOGA.js} +1 -1
  33. package/dist/m/{c-KQKKIVLW.js → c-FBOO2UY3.js} +1 -1
  34. package/dist/m/{c-RTSRGGTP.js → c-FJDTDGDA.js} +1 -1
  35. package/dist/m/{c-MQKSKQHG.js → c-FKTALXNV.js} +1 -1
  36. package/dist/m/{c-GNIVHLEJ.js → c-GDNZEOOZ.js} +1 -1
  37. package/dist/m/{c-SUXHUL7U.js → c-GEAG5XAK.js} +1 -1
  38. package/dist/m/{c-UM7VZDTW.js → c-GY4BREDT.js} +1 -1
  39. package/dist/m/{c-4PO3YGDC.js → c-HGG7M2S3.js} +1 -1
  40. package/dist/m/c-HKSLOVU4.js +1 -0
  41. package/dist/m/{c-KJHVB2KG.js → c-HXGNH6GY.js} +1 -1
  42. package/dist/m/{c-FLEIU6CG.js → c-IESDKFO6.js} +1 -1
  43. package/dist/m/{c-SNVDWMM3.js → c-IJHF4IO6.js} +1 -1
  44. package/dist/m/{c-SGGGCIVD.js → c-IL5IVTOG.js} +1 -1
  45. package/dist/m/c-JB5YU57S.js +1 -0
  46. package/dist/m/{c-MP6S4AZU.js → c-JD2VFAYG.js} +2 -2
  47. package/dist/m/{c-CV4T65JE.js → c-JWW6BHWD.js} +3 -3
  48. package/dist/m/{c-GM7OA6EJ.js → c-K4M76VPM.js} +1 -1
  49. package/dist/m/{c-RXTPKCMC.js → c-KU3PX7LL.js} +1 -1
  50. package/dist/m/{c-2PVGJFI5.js → c-KYTVZT4D.js} +1 -1
  51. package/dist/m/{c-RJKKRWHW.js → c-M7ITYKMF.js} +1 -1
  52. package/dist/m/c-MFB74XVN.js +3 -0
  53. package/dist/m/c-MOP5KBWZ.js +1 -0
  54. package/dist/m/{c-YB3CDTDZ.js → c-MUM53J5J.js} +1 -1
  55. package/dist/m/{c-YK2L436K.js → c-MV7TEUTD.js} +1 -1
  56. package/dist/m/{c-LA2BW7UY.js → c-MZCOGNZL.js} +1 -1
  57. package/dist/m/{c-YGGO2G2L.js → c-N4GEJMXW.js} +1 -1
  58. package/dist/m/{c-NQAPWM7O.js → c-NADLYVAH.js} +1 -1
  59. package/dist/m/{c-RS77ODLY.js → c-NL4I4KNG.js} +1 -1
  60. package/dist/m/{c-6UPEKW6O.js → c-O2QZDVZ2.js} +1 -1
  61. package/dist/m/{c-LTSPHVAK.js → c-OBMZASYQ.js} +1 -1
  62. package/dist/m/{c-LUXAB2XI.js → c-ORLPQFNU.js} +1 -1
  63. package/dist/m/c-P73XPAOA.js +1 -0
  64. package/dist/m/{c-PWJSJEJU.js → c-PNZIJFG7.js} +1 -1
  65. package/dist/m/{c-ZKKAF4UT.js → c-PRZNJUYZ.js} +2 -2
  66. package/dist/m/{c-Q6VNQDJE.js → c-Q2CB57UG.js} +1 -1
  67. package/dist/m/{c-TWCXBWS3.js → c-QCDVDZIR.js} +1 -1
  68. package/dist/m/{c-P5NKFRL4.js → c-QDVG4NAZ.js} +1 -1
  69. package/dist/m/c-QWB53KRW.js +1 -0
  70. package/dist/m/{c-GNI7WHKF.js → c-RGAALCDU.js} +1 -1
  71. package/dist/m/c-RHPHMTF2.js +2 -0
  72. package/dist/m/{c-BULK5J4M.js → c-RWJ3OSCP.js} +1 -1
  73. package/dist/m/{c-5FXEKU4Y.js → c-SEVOS4UE.js} +1 -1
  74. package/dist/m/{c-PMRRMT72.js → c-SFYTKRU7.js} +1 -1
  75. package/dist/m/{c-47IBJGPQ.js → c-SFZRY6RT.js} +1 -1
  76. package/dist/m/{c-QENKN7QI.js → c-SGG6MUXW.js} +1 -1
  77. package/dist/m/c-SMLAJASY.js +1 -0
  78. package/dist/m/{c-VSEUFJAO.js → c-SUAC5RSG.js} +1 -1
  79. package/dist/m/{c-6RGEQNVV.js → c-SXZYVXD6.js} +1 -1
  80. package/dist/m/{c-ZFO5RW6G.js → c-TCFVG3I4.js} +1 -1
  81. package/dist/m/{c-PZRWZZRO.js → c-TLQINES7.js} +2 -2
  82. package/dist/m/c-U4TWEIKZ.js +1 -0
  83. package/dist/m/{c-FZRZPIFS.js → c-U65PREMC.js} +1 -1
  84. package/dist/m/{c-7YETSAVY.js → c-U7F66I64.js} +1 -1
  85. package/dist/m/{c-WOELSNWS.js → c-UD4VDBGJ.js} +1 -1
  86. package/dist/m/{c-VI3BJHCM.js → c-V2FPBRO6.js} +1 -1
  87. package/dist/m/{c-2OIKXOY6.js → c-V4B3RUWB.js} +1 -1
  88. package/dist/m/{c-O5ARALMJ.js → c-V5L6HWVL.js} +1 -1
  89. package/dist/m/c-VMXCX52Q.js +1 -0
  90. package/dist/m/{c-PVJMJL3P.js → c-VO6RSFCO.js} +1 -1
  91. package/dist/m/c-WDFQ7PBU.js +1 -0
  92. package/dist/m/{c-OG6GFSUX.js → c-WGQVSKKC.js} +1 -1
  93. package/dist/m/c-WSTUCBHV.js +1 -0
  94. package/dist/m/{c-EY5KDUZF.js → c-WVCT3ITX.js} +1 -1
  95. package/dist/m/{c-SE3SIY7Y.js → c-X3V4RWMA.js} +1 -1
  96. package/dist/m/{c-HIX5H5L7.js → c-XHZIKRCV.js} +1 -1
  97. package/dist/m/{c-2BLVA6IN.js → c-XQTERKQA.js} +1 -1
  98. package/dist/m/{c-A5ZBFICF.js → c-YFO6ZJLQ.js} +1 -1
  99. package/dist/m/{c-PD5AKHY3.js → c-YX2MC7YL.js} +1 -1
  100. package/dist/m/{c-OYHI67B6.js → c-YYGUHSZR.js} +1 -1
  101. package/dist/m/{c-YYMZWQLR.js → c-Z2OZFX3N.js} +1 -1
  102. package/dist/m/{c-AT7GLQGV.js → c-ZEA3DCG3.js} +1 -1
  103. package/dist/m/{c-75N3KG35.js → c-ZJG6C2WL.js} +1 -1
  104. package/dist/m/{c-FVPNX2GO.js → c-ZORVIXUR.js} +1 -1
  105. package/dist/m/{c-FV46ZKGX.js → c-ZPI7M5U5.js} +1 -1
  106. package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ZI6K6UGM.js} +1 -1
  107. package/dist/m/core/{autosave-4P66VXX7.js → autosave-BSM2ABJL.js} +1 -1
  108. package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-7EQLDYPA.js} +1 -1
  109. package/dist/m/core/draft-mode-M5XNYBMY.js +1 -0
  110. package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-5BYP24GC.js} +1 -1
  111. package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-73OXKK5H.js} +1 -1
  112. package/dist/m/core/{persist-AIJGTKKI.js → persist-2SZO3PCE.js} +1 -1
  113. package/dist/m/core/{recovery-LPB6X72P.js → recovery-VZF7RJZF.js} +1 -1
  114. package/dist/m/core/{save-FV4ZCKBI.js → save-K6Q47J2X.js} +1 -1
  115. package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-U3Z44XBO.js} +1 -1
  116. package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-CFMF6GHD.js} +1 -1
  117. package/dist/m/core/{snapshot-FJP5IJL3.js → snapshot-PWCHYFL6.js} +1 -1
  118. package/dist/m/core/unsaved-warning-2WRZLJH5.js +1 -0
  119. package/dist/m/events/{index-B6TGDYMG.js → index-YKQIBZE5.js} +1 -1
  120. package/dist/m/internals/index-FSLC4M7J.js +1 -0
  121. package/dist/m/lib/{cache-bust-NH7VBWHO.js → cache-bust-BL4KUQAJ.js} +1 -1
  122. package/dist/m/loader-RC54UV7H.js +1 -0
  123. package/dist/m/manifest-3B84C829.js +1 -0
  124. package/dist/m/manifest-edit-1CB79AD4.js +1 -0
  125. package/dist/m/manifest.json +1215 -1129
  126. package/dist/m/options/{options-55LQHSJ4.js → options-LKIZ7YTG.js} +1 -1
  127. package/dist/m/page/{editor-KAZDJNND.js → editor-PKSPJ2XJ.js} +2 -2
  128. package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-3LG6MGS3.js} +1 -1
  129. package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
  130. package/dist/m/plugins/{demo-JIR7DXJA.js → demo-LMCMBILC.js} +1 -1
  131. package/dist/m/plugins/{derive-QVI3LI3A.js → derive-7DR5TUDH.js} +1 -1
  132. package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-CTKX4EY7.js} +1 -1
  133. package/dist/m/plugins/{live-UAJGWRNP.js → live-IFLL6YN5.js} +1 -1
  134. package/dist/m/plugins/{menu-BFJ644YX.js → menu-YJZ3JWBC.js} +1 -1
  135. package/dist/m/plugins/page-GTDPYGE4.js +2 -0
  136. package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-NMCCU6MF.js} +1 -1
  137. package/dist/m/plugins/{people-VBDR5Y22.js → people-6WIWCZEG.js} +1 -1
  138. package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-L3BZMX4I.js} +3 -3
  139. package/dist/m/plugins/sortable-365SRRL3.js +1 -0
  140. package/dist/m/plugins/{source-ZXDD7F6X.js → source-7LSGOV46.js} +1 -1
  141. package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-UES4KS7B.js} +1 -1
  142. package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-MMPZUCDM.js} +1 -1
  143. package/dist/m/plugins/{upload-DNJNW6H3.js → upload-NZET3DNQ.js} +1 -1
  144. package/dist/m/plugins/visitor-drafts-V5YB7XDH.js +20 -0
  145. package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-TLECTYWS.js} +1 -1
  146. package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-QDV2MYJN.js} +1 -1
  147. package/dist/m/plugins/wire-3YGUO3GE.js +1 -0
  148. package/dist/m/{reader-core-F6RY3V64.js → reader-core-VR7EL4WV.js} +2 -2
  149. package/dist/m/sync/live-sync-XMXXZS3G.js +1 -0
  150. package/dist/m/vendor/sap-core.vendor-JANUQULT.js +7 -0
  151. package/dist/offline-manifest.json +724 -664
  152. package/docs/guide.md +1 -0
  153. package/entries/clay-data.js +1 -1
  154. package/entries/sap.js +6 -5
  155. package/package.json +2 -2
  156. package/src/core/draft-mode.js +11 -0
  157. package/src/core/save-core.js +16 -17
  158. package/src/core/save.js +1 -1
  159. package/src/core/unsaved-warning.js +2 -2
  160. package/src/lib/dirty-gate.js +19 -0
  161. package/src/lib/idb-error-code.js +20 -0
  162. package/src/loader.js +2 -0
  163. package/src/plugins/menu/visitor.js +33 -1
  164. package/src/plugins/page.js +19 -5
  165. package/src/plugins/sortable.js +7 -0
  166. package/src/plugins/visitor-drafts/commit.js +350 -0
  167. package/src/plugins/visitor-drafts/idb.js +227 -0
  168. package/src/plugins/visitor-drafts/prompt.js +23 -3
  169. package/src/plugins/visitor-drafts/recovery-menu.js +118 -0
  170. package/src/plugins/visitor-drafts/recovery.js +64 -0
  171. package/src/plugins/visitor-drafts/session.js +393 -0
  172. package/src/plugins/visitor-drafts/store.js +247 -27
  173. package/src/plugins/visitor-drafts.js +177 -28
  174. package/src/sync/live-sync.js +1 -1
  175. package/src/sync/wire-profile.js +9 -4
  176. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  177. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  178. package/src/vendor/hypercms.vendor.js +16 -16
  179. package/src/vendor/richclay.vendor.js +1 -1
  180. package/src/vendor/sap-core.vendor.js +6 -5
  181. package/src/vendor/sap-ownership.vendor.js +181 -11
  182. package/tool-generation/system.md +1 -1
  183. package/dist/m/attrs/save-freeze-JZHKRQLR.js +0 -1
  184. package/dist/m/c-234RSEIW.js +0 -1
  185. package/dist/m/c-2YIJ6ZA6.js +0 -1
  186. package/dist/m/c-H2H7TI6O.js +0 -1
  187. package/dist/m/c-HSNGLIFB.js +0 -3
  188. package/dist/m/c-JMDKPBMC.js +0 -4
  189. package/dist/m/c-LO4C4J77.js +0 -1
  190. package/dist/m/c-N7JPUPGC.js +0 -1
  191. package/dist/m/c-NEXFVJIT.js +0 -1
  192. package/dist/m/c-R23UHMXG.js +0 -1
  193. package/dist/m/c-T527QHOB.js +0 -1
  194. package/dist/m/c-V2U3F4QX.js +0 -1
  195. package/dist/m/c-VG5XIN4T.js +0 -1
  196. package/dist/m/c-YINHPF4V.js +0 -1
  197. package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
  198. package/dist/m/core/unsaved-warning-FF63QGKI.js +0 -1
  199. package/dist/m/internals/index-AX4CG7B2.js +0 -1
  200. package/dist/m/loader-5IA5OJ3J.js +0 -1
  201. package/dist/m/manifest-98258645.js +0 -1
  202. package/dist/m/manifest-edit-ABF56000.js +0 -1
  203. package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
  204. package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
  205. package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
  206. package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
  207. package/dist/m/sync/live-sync-M2PP2IWY.js +0 -1
  208. package/dist/m/vendor/sap-core.vendor-OA5SKL7Q.js +0 -6
  209. /package/dist/m/plugins/{richclay-CBLZRFB4.js.LEGAL.txt → richclay-L3BZMX4I.js.LEGAL.txt} +0 -0
@@ -1,9 +1,21 @@
1
1
  import { extractData, applyData, engine } from "../../vendor/hyper-html-api-data.vendor.js";
2
- import { draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
2
+ import { draftGate, draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
3
3
 
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,12 +67,13 @@ 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
 
62
74
  const STORE_SEL = 'script[type="application/json"][merge][clay~="live"]';
63
75
  const ARTICLE_SEL = "article[data-blockeditor]";
76
+ const HANDLER_SEL = "[onbeforesave], [onbeforesnapshot]";
64
77
  const isPagePart = (name) => name === "blockeditor" || /^blockeditor\.\d+$/.test(name);
65
78
  const canonical = (live) => JSON.stringify(Object.keys(live).sort().map((name) => [name, live[name]]));
66
79
 
@@ -78,6 +91,9 @@ function readUnit(source) {
78
91
 
79
92
  const sameUnit = (a, b) => (!a || !b ? a === b : a.blockeditor.rev === b.blockeditor.rev && canonical(a.live) === canonical(b.live));
80
93
 
94
+ // One page content, one mark: two pages `sameUnit` calls the same carry the same one.
95
+ export const unitMark = (unit) => (isPlain(unit?.live) && isPlain(unit?.blockeditor) ? `${unit.blockeditor.rev}:${textMark(canonical(unit.live))}` : null);
96
+
81
97
  function unitOf(record) {
82
98
  if (!isPlain(record?.live) || !isPlain(record?.blockeditor)) return null;
83
99
  const { live, blockeditor } = record;
@@ -189,7 +205,7 @@ export function merge3(base, mine, theirs) {
189
205
  return merged;
190
206
  }
191
207
 
192
- function storageFailure(doc, error) {
208
+ export function storageFailure(doc, error) {
193
209
  setDraftStoreFailed(true);
194
210
  doc.dispatchEvent(new CustomEvent("clay:drafts-store-failed", { detail: { error } }));
195
211
  const failure = new Error("Couldn't save in this browser. Storage is full or turned off.");
@@ -254,13 +270,20 @@ function withoutSiteKey(value) {
254
270
  return rest;
255
271
  }
256
272
 
257
- export function createStore({ doc = document, storage = safeStorage(), now = () => new Date(), path = draftPath(), fallback = null } = {}) {
273
+ export function createStore({ doc = document, storage = safeStorage(), gate = draftGate, now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
258
274
  const slot = draftSlot(doc);
259
275
  const key = draftKey(slot, path);
260
276
  const rules = withoutSiteKey(engine.findRulesIn(doc, "api").rules);
261
277
 
262
278
  let lastRaw = null;
263
279
  let base = null;
280
+ // The served page's data, read before any restore, and the stored data this tab's next
281
+ // operation is built on: the record it restored or last took in with its own operations
282
+ // laid over, or the served page's while nothing is stored.
283
+ let seed = null;
284
+ let tip = null;
285
+ // Nothing of a draft is on this page yet: no restore, no operation, nothing taken in.
286
+ let fresh = true;
264
287
  let unparked = null;
265
288
  let stopped = false;
266
289
  // This tab's own last write: its record id, its ancestry, and the data of the record it
@@ -317,7 +340,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
317
340
  return true;
318
341
  }
319
342
 
320
- const newId = () => Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
343
+ const newId = newRecordId;
321
344
 
322
345
  // A record's ancestry is the ids it descends from, nearest first, ending at the empty
323
346
  // slot it grew from, or at a record from a runtime that kept no ids, named by its bytes.
@@ -328,11 +351,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
328
351
  try { return "root:" + (storage?.getItem(resetKey) ?? ""); } catch { return "root:"; }
329
352
  }
330
353
 
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
- }
354
+ const legacyId = (raw) => "legacy:" + textMark(raw);
336
355
 
337
356
  function lineageOf(record, raw) {
338
357
  if (!record) return null;
@@ -456,31 +475,37 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
456
475
  return null;
457
476
  }
458
477
 
459
- function parkedResult(raw) {
478
+ function parkedResult({ raw, record }) {
460
479
  const parkedKey = park(raw);
461
480
  if (!parkedKey) lastRaw = raw;
462
481
  base = live();
482
+ tip = record?.data ?? base;
463
483
  return parkedKey
464
484
  ? { restored: false, hadDraft: true, parked: true }
465
485
  : { restored: false, hadDraft: true, parked: false, blocked: true };
466
486
  }
467
487
 
468
- function restore() {
469
- const found = read();
488
+ const seedData = () => (seed ??= live());
489
+
490
+ // `found` is the record to put on the page: the one `localStorage` holds, or the
491
+ // database's when the page waited for it (null: it holds none).
492
+ function restore(found = read()) {
493
+ seedData();
470
494
  if (!found) {
471
- base = live();
495
+ base = tip = seed;
472
496
  whenArticle(readServed);
473
497
  return { restored: false, hadDraft: false };
474
498
  }
475
- if (!found.record) return parkedResult(found.raw);
499
+ fresh = false;
500
+ if (!found.record) return parkedResult(found);
476
501
  const unit = unitOf(found.record);
477
502
  const hasData = Object.keys(found.record.data).length > 0;
478
503
  const fitted = hasData ? fit(found.record.data) : { data: {}, dropped: [] };
479
504
  const dataFits = Boolean(fitted) && (!hasData || Object.keys(fitted.data).length > 0);
480
505
  // The page does not depend on the rules: a page is placed even when no saved field fits,
481
506
  // 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);
507
+ if (!unit && !dataFits) return parkedResult(found);
508
+ if (!unit && !hasData) return parkedResult(found);
484
509
  if (hasData && dataFits) applyData(doc, fitted.data, rules);
485
510
  if (unit) {
486
511
  whenArticle(() => {
@@ -493,6 +518,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
493
518
  }
494
519
  lastRaw = found.raw;
495
520
  base = live();
521
+ tip = found.record.data;
496
522
  if (!dataFits || fitted.dropped.length) {
497
523
  const parkedKey = park(found.raw, { keepSlot: true });
498
524
  return { restored: true, hadDraft: true, data: dataFits ? fitted.data : {}, partial: true, parked: Boolean(parkedKey), blocked: !parkedKey };
@@ -552,6 +578,165 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
552
578
  return fitted.data;
553
579
  }
554
580
 
581
+ // With the database as the record, a save is an operation: for each key the page changed,
582
+ // the stored value it was built on (`b`, absent when nothing was) and the value now (`m`).
583
+ // `tab.ops` are the ones the database has not confirmed, oldest first, under the Start
584
+ // over marker this page booted on; `sizes` runs beside them. A copy that fits goes into
585
+ // this tab's journal slot at once, so closing the tab loses nothing: `journaled` is the
586
+ // last sequence number the slot holds.
587
+ const tab = { id: newId(), reset: "", acked: 0, seq: 0, journaled: 0, ops: [], sizes: [] };
588
+ try { tab.reset = storage?.getItem(resetKey) ?? ""; } catch {}
589
+ try {
590
+ const known = JSON.parse(window.sessionStorage.getItem(key + ":generation"));
591
+ if (known?.marker === tab.reset && typeof known.reset === "string") tab.reset = known.reset;
592
+ } catch {}
593
+ const slotKey = `${key}:op:${tab.id}`;
594
+ const chain = () => ({ tab: tab.id, reset: tab.reset, acked: tab.acked, at: now().getTime(), ops: tab.ops });
595
+
596
+ function journal() {
597
+ try {
598
+ if (!tab.ops.length) {
599
+ tab.journaled = tab.seq;
600
+ storage.removeItem(slotKey);
601
+ return;
602
+ }
603
+ const text = JSON.stringify(chain());
604
+ if (text.length > slotLimit) return;
605
+ storage.setItem(slotKey, text);
606
+ tab.journaled = tab.seq;
607
+ } catch {}
608
+ }
609
+
610
+ // What the page held before its unconfirmed operations, for a page load that falls back
611
+ // to `localStorage` after it made some.
612
+ let before = null;
613
+
614
+ // The dirty gate's token for the snapshot `base` was last compared with: taken before
615
+ // that snapshot, in its task. Null while that is not known.
616
+ let capturedAt = null;
617
+
618
+ const pageOf = (html) => (html === undefined ? doc : new DOMParser().parseFromString(html, "text/html"));
619
+
620
+ // `at` is the gate token of the snapshot `html` was serialized from.
621
+ function capture(html, { reset = tab.reset, at = null } = {}) {
622
+ if (stopped) return null;
623
+ const parsed = pageOf(html);
624
+ const op = captureData(extractData(parsed, rules), recordable(readUnit(parsed)), reset);
625
+ capturedAt = at;
626
+ return op;
627
+ }
628
+
629
+ // The page as it is now.
630
+ function captureNow(reset = tab.reset) {
631
+ if (stopped) return null;
632
+ const at = gate()?.token() ?? null;
633
+ return capture(window.clay.getHTML(), { reset, at });
634
+ }
635
+
636
+ // The last capture of a generation that has ended. A capture runs the page's save
637
+ // handlers, and a handler that writes a changing value makes a field differ with no
638
+ // edit behind it. So on a page with a save handler that has recorded no edit since
639
+ // `base` was captured, the fields are what `base` holds, and only the page editor's
640
+ // part, which no handler writes, is compared.
641
+ function captureLate(reset = tab.reset) {
642
+ if (stopped) return null;
643
+ const settled = capturedAt !== null && doc.querySelector(HANDLER_SEL) !== null && gate()?.unchangedSince(capturedAt) === true;
644
+ const parsed = pageOf(window.clay?.getHTML?.());
645
+ return captureData(settled ? base : extractData(parsed, rules), recordable(readUnit(parsed)), reset);
646
+ }
647
+
648
+ function captureData(mine, mineUnit, reset = tab.reset) {
649
+ if (unparked != null && park(unparked, { keepSlot: true }) === null) {
650
+ throw storageFailure(doc, new Error("an earlier saved copy could not be kept aside"));
651
+ }
652
+ // Too much is waiting: nothing more is taken until it drains, and the page keeps it.
653
+ if (tab.sizes.reduce((sum, size) => sum + size, 0) >= pendingLimit) throw storageFailure(doc, new Error("too many changes are waiting to be saved"));
654
+ const unitChanged = !sameUnit(mineUnit, editorUnit);
655
+ const keys = {};
656
+ for (const name of Object.keys(mine)) {
657
+ if (name in base && fitsInto(mine[name], base[name])) continue;
658
+ // Fields the stored value has and this page's rules lack are carried through.
659
+ keys[name] = name in tip ? { b: tip[name], m: complete(mine[name], tip[name]) } : { m: mine[name] };
660
+ }
661
+ if (!Object.keys(keys).length && !unitChanged) {
662
+ if (!tab.ops.length) {
663
+ setDraftStoreFailed(false);
664
+ doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data: mine, unchanged: true } }));
665
+ }
666
+ return null;
667
+ }
668
+ if (!tab.ops.length) before = { base, editorUnit, seenUnit };
669
+ const op = { seq: ++tab.seq, savedAt: now().toISOString(), keys, ...(unitChanged && { unit: { seen: unitMark(seenUnit), to: mineUnit } }) };
670
+ base = { ...base };
671
+ tip = { ...tip };
672
+ for (const name of Object.keys(keys)) {
673
+ base[name] = mine[name];
674
+ tip[name] = keys[name].m;
675
+ }
676
+ if (unitChanged) editorUnit = seenUnit = mineUnit;
677
+ fresh = false;
678
+ tab.reset = reset;
679
+ tab.ops.push(op);
680
+ tab.sizes.push(JSON.stringify(op).length);
681
+ journal();
682
+ return op;
683
+ }
684
+
685
+ // A hide's synchronous half of a save. False while an operation is neither in the
686
+ // journal slot nor confirmed: closing the tab now could lose it.
687
+ function keep() {
688
+ if (stopped) return true;
689
+ try {
690
+ if (changed()) captureNow();
691
+ } catch { return false; }
692
+ return tab.journaled === tab.seq;
693
+ }
694
+
695
+ function acknowledge(seq) {
696
+ if (seq <= tab.acked) return false;
697
+ tab.acked = seq;
698
+ const done = tab.ops.filter((op) => op.seq <= seq).length;
699
+ tab.ops.splice(0, done);
700
+ tab.sizes.splice(0, done);
701
+ journal();
702
+ return done > 0;
703
+ }
704
+
705
+ function drop() {
706
+ tab.ops.length = 0;
707
+ tab.sizes.length = 0;
708
+ journal();
709
+ }
710
+
711
+ // The database did not open: what the journal holds goes back to being this page's
712
+ // unsaved changes, for `write` to save.
713
+ function release() {
714
+ if (tab.ops.length) ({ base, editorUnit, seenUnit } = before);
715
+ capturedAt = null;
716
+ drop();
717
+ }
718
+
719
+ // A page with nothing unsaved takes in what the database holds: the keys whose stored
720
+ // value is no longer the one this tab was built on.
721
+ function take(data) {
722
+ const moved = Object.fromEntries(Object.entries(data).filter(([name, value]) => !same(value, tip[name])));
723
+ if (!Object.keys(moved).length) return false;
724
+ const shown = adopt(moved);
725
+ tip = { ...tip, ...moved };
726
+ if (shown) base = { ...base, ...shown };
727
+ fresh = false;
728
+ return true;
729
+ }
730
+
731
+ // The generation this page shows has ended, by this tab's Start over or another tab's:
732
+ // nothing more is saved from it.
733
+ function stop({ keepPending = false } = {}) {
734
+ stopped = true;
735
+ if (!keepPending) drop();
736
+ }
737
+
738
+ const journalSlots = () => storedKeys().filter((name) => name.startsWith(key + ":op:"));
739
+
555
740
  function write(html) {
556
741
  if (stopped) return null;
557
742
  if (unparked != null && park(unparked, { keepSlot: true }) === null) {
@@ -578,7 +763,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
578
763
  const id = newId();
579
764
  const raw = stored && sameUnit(unit, theirs)
580
765
  ? stored
581
- : JSON.stringify({ v: RECORD_VERSION, savedAt: now().toISOString(), id, ancestry, data, ...(unit && { live: unit.live, blockeditor: unit.blockeditor }) });
766
+ : JSON.stringify({ v: RECORD_VERSION, savedAt: now().toISOString(), id, ancestry, reset: tab.reset, data, ...(unit && { live: unit.live, blockeditor: unit.blockeditor }) });
582
767
  if (raw !== stored) {
583
768
  try {
584
769
  if (!storage) throw new Error("no storage");
@@ -634,11 +819,15 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
634
819
  return true;
635
820
  }
636
821
 
637
- // The page another tab recorded since this tab last read or wrote one, or null.
638
- function externalUnit() {
822
+ // The page another tab recorded since this tab last read or wrote one, or null. `record`
823
+ // is the database's; without one the copy in `localStorage` is read.
824
+ function externalUnit(record) {
639
825
  if (stopped) return null;
640
- const raw = getRaw();
641
- const theirs = raw == null ? null : unitOf(parse(raw));
826
+ if (record === undefined) {
827
+ const raw = getRaw();
828
+ record = raw == null ? null : parse(raw);
829
+ }
830
+ const theirs = unitOf(record);
642
831
  return theirs && !sameUnit(theirs, seenUnit) ? theirs : null;
643
832
  }
644
833
 
@@ -648,12 +837,43 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
648
837
  }
649
838
 
650
839
  function clear() {
651
- stopped = true;
840
+ stop();
652
841
  unparked = null;
653
842
  setDraftStoreFailed(false);
654
843
  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 };
844
+ const match = /^(\d+)~/.exec(tab.reset);
845
+ const gen = match ? Number(match[1]) : tab.reset === "" ? 1 : null;
846
+ try { storage.setItem(resetKey, gen === null ? newId() : `${gen + 1}~${newId()}`); } catch {}
847
+ }
848
+
849
+ return {
850
+ slot, key, rules, read, restore, write, writeLive, flush, changed, adoptExternal, externalUnit, adoptedUnit, park, clear, snapshot,
851
+ tab: tab.id, slotKey, chain, capture, captureNow, captureLate, keep, acknowledge, release, take, stop, journalSlots,
852
+ seed: seedData,
853
+ servedUnit: () => { readServed(); return served; },
854
+ fitsInto,
855
+ same,
856
+ // For the session, which is fetched after the boot and imports nothing from here.
857
+ merge3,
858
+ unitMark,
859
+ newId,
860
+ failed: (error) => storageFailure(doc, error),
861
+ generation: () => tab.reset,
862
+ enter: (reset) => { tab.reset = reset; },
863
+ fresh: () => fresh,
864
+ shown: () => base !== null,
865
+ pending: () => tab.ops.length,
866
+ unsafe: () => tab.journaled < tab.seq,
867
+ stopped: () => stopped,
868
+ // What `localStorage` holds at the draft's key, whatever wrote it: a record, the head of
869
+ // a draft whose full record is only in the database (`idb`), or nothing readable.
870
+ held() {
871
+ try {
872
+ const held = JSON.parse(getRaw());
873
+ return isPlain(held) ? held : null;
874
+ } catch { return null; }
875
+ },
876
+ // A draft is kept when `localStorage` holds its copy or head, or any tab's journal.
877
+ kept: () => Boolean(read()) || tab.ops.length > 0 || journalSlots().length > 0,
878
+ };
659
879
  }
@@ -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 { draftGate, 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,102 @@ 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, at = null) => {
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) [at, html] = [draftGate()?.token() ?? null, window.clay.getHTML()];
80
+ if (direct) return void store.write(html);
81
+ store.capture(html, { at });
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
+ // A link marked clay-wait-drafts (the host's download and host actions) waits for this
135
+ // page's last changes to reach the download page before it navigates, like the prompt's
136
+ // Download button. A new tab or a modified click leaves this page open, so it does not wait.
137
+ let leaving = false;
138
+ document.addEventListener("click", async (event) => {
139
+ // composedPath reaches into open shadow roots such as Hyperclay's trial bar.
140
+ const link = event.composedPath().find((node) => node instanceof Element && node.matches("a[clay-wait-drafts][href]"));
141
+ if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
142
+ if (link.target && link.target !== "_self") return;
143
+ if (leaving) { event.preventDefault(); return; }
144
+ const waiting = settle();
145
+ if (!waiting) return;
146
+ event.preventDefault();
147
+ leaving = true;
148
+ let timer;
149
+ await Promise.race([waiting, new Promise((resolve) => { timer = setTimeout(resolve, DOWNLOAD_WAIT_MS); })]);
150
+ clearTimeout(timer);
151
+ leaving = false;
152
+ window.location.assign(link.href);
153
+ }, true);
154
+
155
+ // Another tab's save reaches a page on the database through its session. With
156
+ // `localStorage` as the record it arrives as that key's storage event, once the page
157
+ // editor listens (an event before that is missed), and when the tab comes back from the
158
+ // page cache.
65
159
  const takeOtherTab = () => {
160
+ if (!direct || refused || !restoredDetail) return;
66
161
  // Read before adopting: adopting reads the record as this tab's own.
67
162
  const unit = store.externalUnit();
68
163
  // A page another tab wrote goes to the page editor, which merges it into its own.
@@ -72,9 +167,36 @@ if (isDraftMode && slot && rulesTag) {
72
167
  window.addEventListener("storage", (event) => { if (event.key === store.key) takeOtherTab(); });
73
168
  document.addEventListener("clay:drafts-blockeditor-listening", takeOtherTab);
74
169
  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);
170
+
171
+ const held = store.held();
172
+ const head = held?.v === 2 && held.idb === true;
173
+ const foreignReset = !direct && held?.v === 1 && held.w === undefined && store.generation() !== "";
174
+ if (head || foreignReset || (!direct && held?.w && held.w.reset !== store.generation())) {
175
+ // The full draft is only in the database, or this copy is from before the last Start
176
+ // over: the page waits for the database's record, and plugins that read the document
177
+ // wait with the page.
178
+ restoring = opening.then(() => session?.first).then((result) => {
179
+ if (result) return finish(store.restore(result.record && { raw: JSON.stringify(result.record), record: result.record }));
180
+ throw new Error("the draft database did not open");
181
+ }).catch((error) => {
182
+ if (!head) {
183
+ if (!foreignReset) { try { local?.removeItem(store.key); } catch {} }
184
+ return finish(store.restore());
185
+ }
186
+ refused = true;
187
+ storageFailure(document, error);
188
+ finish({ restored: false, hadDraft: true, blocked: true });
189
+ });
190
+ } else {
191
+ finish(store.restore());
192
+ // The database did not open: `localStorage` is the record for this page load, and what
193
+ // this tab journaled while it waited is saved there.
194
+ opening.then(() => {
195
+ if (session) return;
196
+ store.release();
197
+ store.flush();
198
+ });
199
+ }
78
200
  } else if (!slot || !rulesTag) {
79
201
  const missing = [];
80
202
  if (!slot) missing.push('a visitor-drafts="<slot>" attribute on <html>');
@@ -84,18 +206,33 @@ if (isDraftMode && slot && rulesTag) {
84
206
  ready = mountChip();
85
207
  }
86
208
 
87
- export { ready };
209
+ export { ready, restoring };
88
210
 
89
211
  export const visitorDrafts = store ? {
90
212
  active: true,
91
213
  slot: store.slot,
92
214
  key: store.key,
93
- hasDraft: () => Boolean(store.read()),
215
+ hasDraft: () => store.kept(),
94
216
  restored: () => restoredDetail,
95
- saveNow: () => store.writeLive(),
217
+ saveNow() {
218
+ if (refused || !restoredDetail) return false;
219
+ if (direct) return store.writeLive();
220
+ try { store.captureNow(); } catch { return false; }
221
+ session?.sync().catch(() => {});
222
+ return !store.unsafe();
223
+ },
96
224
  reset() {
97
- store.clear();
98
- window.location.reload();
225
+ if (!database) {
226
+ store.clear();
227
+ window.location.reload();
228
+ return true;
229
+ }
230
+ // The generation ends in the database before the page loads again.
231
+ opening.then(() => {
232
+ if (session) return session.startOver();
233
+ if (direct && !refused) return store.clear();
234
+ throw new Error("the draft database did not open");
235
+ }).then(() => window.location.reload(), (error) => storageFailure(document, error));
99
236
  return true;
100
237
  },
101
238
  onRestore(fn) {
@@ -109,6 +246,18 @@ export const visitorDrafts = store ? {
109
246
  editOriginal,
110
247
  backToDraft,
111
248
  downloadUrl: () => downloadLink,
112
- download: () => ready.then((prompt) => { store.flush(); return prompt?.download(); }),
249
+ download: () => ready.then(async (prompt) => {
250
+ const saved = await settle();
251
+ return prompt?.download(saved === false);
252
+ }),
113
253
  source,
114
254
  } : inertVisitorDrafts();
255
+
256
+ // Internal to the visitor menu; owner saving and the public visitorDrafts API stay separate.
257
+ export async function recoveryDrafts() {
258
+ await restoring;
259
+ await opening;
260
+ if (!store || !session || refused) return null;
261
+ const { createRecovery } = await import("./visitor-drafts/recovery.js");
262
+ return createRecovery({ store, session });
263
+ }
@@ -933,7 +933,7 @@ class LiveSync {
933
933
  // Whichever wire discovery selected. start() does not call connect() until the
934
934
  // profile is known, so this is never null on the normal path.
935
935
  const profile = this._profile || WIRE_PROFILES.legacy;
936
- const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId);
936
+ const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId, PAGE_FORMAT);
937
937
  // A frame scheduled while the page showed waits for an animation frame, which a
938
938
  // page hidden since then never runs. Move it to the hidden schedule.
939
939
  if (!this._hiddenHandler) {