@panphora/clayjs 1.24.1 → 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 (190) 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 +2044 -1247
  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-SXGEWPFC.js → c-3E2IK5HU.js} +1 -1
  14. package/dist/m/{c-A4L7APIU.js → c-4AE2A2LK.js} +1 -1
  15. package/dist/m/{c-YDW4VCHP.js → c-4Q6QIODW.js} +1 -1
  16. package/dist/m/{c-RO73MKKR.js → c-52OC3OH3.js} +1 -1
  17. package/dist/m/{c-ZGCO4J3H.js → c-5PZYDWTW.js} +1 -1
  18. package/dist/m/c-5Q5XBN56.js +1 -0
  19. package/dist/m/c-5SZ6KFR5.js +1 -0
  20. package/dist/m/{c-ZRGEN6QQ.js → c-AI3H6ETU.js} +2 -2
  21. package/dist/m/{c-7V6MGMX2.js → c-BCWYEG6P.js} +1 -1
  22. package/dist/m/{c-C5AKJVRH.js → c-BJ2RRQBQ.js} +1 -1
  23. package/dist/m/{c-CTGRDT6E.js → c-C6YZNU6E.js} +1 -1
  24. package/dist/m/{c-EDE45NM7.js → c-C7SUMNMI.js} +1 -1
  25. package/dist/m/c-CITLTVQ4.js +4 -0
  26. package/dist/m/{c-STRI73GQ.js → c-CL5LSATX.js} +1 -1
  27. package/dist/m/{c-Z4G2UK6T.js → c-CU33V6RW.js} +1 -1
  28. package/dist/m/{c-6LEYVQJK.js → c-D62HRSZK.js} +1 -1
  29. package/dist/m/{c-DD26VXAP.js → c-DT7E76Z2.js} +1 -1
  30. package/dist/m/{c-P2HKMJAW.js → c-EAV7A6BT.js} +1 -1
  31. package/dist/m/{c-QQFHEODI.js → c-EQ5NBOGA.js} +1 -1
  32. package/dist/m/{c-KQKKIVLW.js → c-FBOO2UY3.js} +1 -1
  33. package/dist/m/{c-JQF2EUCG.js → c-FJDTDGDA.js} +1 -1
  34. package/dist/m/{c-MQKSKQHG.js → c-FKTALXNV.js} +1 -1
  35. package/dist/m/{c-HL6BLJNA.js → c-GDNZEOOZ.js} +1 -1
  36. package/dist/m/{c-SUXHUL7U.js → c-GEAG5XAK.js} +1 -1
  37. package/dist/m/{c-LSVN3OLG.js → c-GY4BREDT.js} +1 -1
  38. package/dist/m/{c-4PO3YGDC.js → c-HGG7M2S3.js} +1 -1
  39. package/dist/m/{c-DDS23EY6.js → c-HXGNH6GY.js} +1 -1
  40. package/dist/m/{c-FLEIU6CG.js → c-IESDKFO6.js} +1 -1
  41. package/dist/m/{c-CJ7QYEWI.js → c-IJHF4IO6.js} +1 -1
  42. package/dist/m/{c-SGGGCIVD.js → c-IL5IVTOG.js} +1 -1
  43. package/dist/m/c-JB5YU57S.js +1 -0
  44. package/dist/m/{c-MP6S4AZU.js → c-JD2VFAYG.js} +2 -2
  45. package/dist/m/{c-CV4T65JE.js → c-JWW6BHWD.js} +3 -3
  46. package/dist/m/{c-42ZNV7LI.js → c-K4M76VPM.js} +1 -1
  47. package/dist/m/{c-6IJEVE6B.js → c-KU3PX7LL.js} +1 -1
  48. package/dist/m/{c-2PVGJFI5.js → c-KYTVZT4D.js} +1 -1
  49. package/dist/m/{c-BGN7RCTJ.js → c-M7ITYKMF.js} +1 -1
  50. package/dist/m/c-MFB74XVN.js +3 -0
  51. package/dist/m/c-MOP5KBWZ.js +1 -0
  52. package/dist/m/{c-YLHTD3KX.js → c-MUM53J5J.js} +1 -1
  53. package/dist/m/{c-YK2L436K.js → c-MV7TEUTD.js} +1 -1
  54. package/dist/m/{c-LA2BW7UY.js → c-MZCOGNZL.js} +1 -1
  55. package/dist/m/{c-XW5TIYYB.js → c-N4GEJMXW.js} +1 -1
  56. package/dist/m/{c-NQAPWM7O.js → c-NADLYVAH.js} +1 -1
  57. package/dist/m/{c-RS77ODLY.js → c-NL4I4KNG.js} +1 -1
  58. package/dist/m/{c-X75G43SZ.js → c-O2QZDVZ2.js} +1 -1
  59. package/dist/m/{c-LTSPHVAK.js → c-OBMZASYQ.js} +1 -1
  60. package/dist/m/{c-LUXAB2XI.js → c-ORLPQFNU.js} +1 -1
  61. package/dist/m/c-P73XPAOA.js +1 -0
  62. package/dist/m/{c-PWJSJEJU.js → c-PNZIJFG7.js} +1 -1
  63. package/dist/m/{c-ZKKAF4UT.js → c-PRZNJUYZ.js} +2 -2
  64. package/dist/m/{c-B7GOFGQR.js → c-Q2CB57UG.js} +1 -1
  65. package/dist/m/{c-TWCXBWS3.js → c-QCDVDZIR.js} +1 -1
  66. package/dist/m/{c-UOOIF2AL.js → c-QDVG4NAZ.js} +1 -1
  67. package/dist/m/c-QWB53KRW.js +1 -0
  68. package/dist/m/{c-GNI7WHKF.js → c-RGAALCDU.js} +1 -1
  69. package/dist/m/c-RHPHMTF2.js +2 -0
  70. package/dist/m/{c-3UW5EJ77.js → c-RWJ3OSCP.js} +1 -1
  71. package/dist/m/{c-5FXEKU4Y.js → c-SEVOS4UE.js} +1 -1
  72. package/dist/m/{c-HDVH3MVC.js → c-SFYTKRU7.js} +1 -1
  73. package/dist/m/{c-47IBJGPQ.js → c-SFZRY6RT.js} +1 -1
  74. package/dist/m/{c-QENKN7QI.js → c-SGG6MUXW.js} +1 -1
  75. package/dist/m/c-SMLAJASY.js +1 -0
  76. package/dist/m/{c-DFKBGSN2.js → c-SUAC5RSG.js} +1 -1
  77. package/dist/m/{c-6RGEQNVV.js → c-SXZYVXD6.js} +1 -1
  78. package/dist/m/{c-ZFO5RW6G.js → c-TCFVG3I4.js} +1 -1
  79. package/dist/m/{c-OCYG4NBQ.js → c-TLQINES7.js} +2 -2
  80. package/dist/m/c-U4TWEIKZ.js +1 -0
  81. package/dist/m/{c-MQYEDF3Q.js → c-U65PREMC.js} +1 -1
  82. package/dist/m/{c-PDBNCJCT.js → c-U7F66I64.js} +1 -1
  83. package/dist/m/{c-LATNQ6Q5.js → c-UD4VDBGJ.js} +1 -1
  84. package/dist/m/{c-PCNJETUA.js → c-V2FPBRO6.js} +1 -1
  85. package/dist/m/{c-ND3Q2G2I.js → c-V4B3RUWB.js} +1 -1
  86. package/dist/m/{c-JWDCQL6J.js → c-V5L6HWVL.js} +1 -1
  87. package/dist/m/c-VMXCX52Q.js +1 -0
  88. package/dist/m/{c-CZHTXGJS.js → c-VO6RSFCO.js} +1 -1
  89. package/dist/m/c-WDFQ7PBU.js +1 -0
  90. package/dist/m/{c-OG6GFSUX.js → c-WGQVSKKC.js} +1 -1
  91. package/dist/m/c-WSTUCBHV.js +1 -0
  92. package/dist/m/{c-FEC6LGFF.js → c-WVCT3ITX.js} +1 -1
  93. package/dist/m/{c-SE3SIY7Y.js → c-X3V4RWMA.js} +1 -1
  94. package/dist/m/{c-HIX5H5L7.js → c-XHZIKRCV.js} +1 -1
  95. package/dist/m/{c-2BLVA6IN.js → c-XQTERKQA.js} +1 -1
  96. package/dist/m/{c-PYG2NGWU.js → c-YFO6ZJLQ.js} +1 -1
  97. package/dist/m/{c-7CM4GMTS.js → c-YX2MC7YL.js} +1 -1
  98. package/dist/m/{c-OYHI67B6.js → c-YYGUHSZR.js} +1 -1
  99. package/dist/m/{c-YYMZWQLR.js → c-Z2OZFX3N.js} +1 -1
  100. package/dist/m/{c-AT7GLQGV.js → c-ZEA3DCG3.js} +1 -1
  101. package/dist/m/{c-T3P3AXEW.js → c-ZJG6C2WL.js} +1 -1
  102. package/dist/m/{c-FVPNX2GO.js → c-ZORVIXUR.js} +1 -1
  103. package/dist/m/{c-SKMIR5HQ.js → c-ZPI7M5U5.js} +1 -1
  104. package/dist/m/core/{admin-attrs-ONRWVXPK.js → admin-attrs-ZI6K6UGM.js} +1 -1
  105. package/dist/m/core/{autosave-TBKAAAFB.js → autosave-BSM2ABJL.js} +1 -1
  106. package/dist/m/core/{conflict-notice-FOEE6I7E.js → conflict-notice-7EQLDYPA.js} +1 -1
  107. package/dist/m/core/draft-mode-M5XNYBMY.js +1 -0
  108. package/dist/m/core/{host-permission-X4BENZEU.js → host-permission-5BYP24GC.js} +1 -1
  109. package/dist/m/core/{is-edit-mode-FFHCQ5DR.js → is-edit-mode-73OXKK5H.js} +1 -1
  110. package/dist/m/core/{persist-6ILUR34S.js → persist-2SZO3PCE.js} +1 -1
  111. package/dist/m/core/{recovery-APWADU4K.js → recovery-VZF7RJZF.js} +1 -1
  112. package/dist/m/core/{save-6HT2LABE.js → save-K6Q47J2X.js} +1 -1
  113. package/dist/m/core/{save-core-EVMNZQ4V.js → save-core-U3Z44XBO.js} +1 -1
  114. package/dist/m/core/{site-edits-G4BOZMV4.js → site-edits-CFMF6GHD.js} +1 -1
  115. package/dist/m/core/{snapshot-FJP5IJL3.js → snapshot-PWCHYFL6.js} +1 -1
  116. package/dist/m/core/unsaved-warning-2WRZLJH5.js +1 -0
  117. package/dist/m/events/{index-B6TGDYMG.js → index-YKQIBZE5.js} +1 -1
  118. package/dist/m/internals/index-FSLC4M7J.js +1 -0
  119. package/dist/m/lib/{cache-bust-NH7VBWHO.js → cache-bust-BL4KUQAJ.js} +1 -1
  120. package/dist/m/{loader-GLLVDUZA.js → loader-RC54UV7H.js} +1 -1
  121. package/dist/m/manifest-3B84C829.js +1 -0
  122. package/dist/m/manifest-edit-1CB79AD4.js +1 -0
  123. package/dist/m/manifest.json +1165 -1115
  124. package/dist/m/options/{options-55LQHSJ4.js → options-LKIZ7YTG.js} +1 -1
  125. package/dist/m/page/{editor-KAZDJNND.js → editor-PKSPJ2XJ.js} +2 -2
  126. package/dist/m/plugins/{ai-edit-LABECCSN.js → ai-edit-3LG6MGS3.js} +1 -1
  127. package/dist/m/plugins/{demo-T7B4RIKL.js → demo-LMCMBILC.js} +1 -1
  128. package/dist/m/plugins/{derive-6LPJOCAV.js → derive-7DR5TUDH.js} +1 -1
  129. package/dist/m/plugins/{indicator-J4ABFARX.js → indicator-CTKX4EY7.js} +1 -1
  130. package/dist/m/plugins/{live-PGBIEMGB.js → live-IFLL6YN5.js} +1 -1
  131. package/dist/m/plugins/{menu-YZL3X2AJ.js → menu-YJZ3JWBC.js} +1 -1
  132. package/dist/m/plugins/{page-WOCNX2IN.js → page-GTDPYGE4.js} +2 -2
  133. package/dist/m/plugins/{page-view-SJODOOGE.js → page-view-NMCCU6MF.js} +1 -1
  134. package/dist/m/plugins/{people-C4TCWFOQ.js → people-6WIWCZEG.js} +1 -1
  135. package/dist/m/plugins/{richclay-Y2NV44NA.js → richclay-L3BZMX4I.js} +2 -2
  136. package/dist/m/plugins/{sortable-XCHQG65Z.js → sortable-365SRRL3.js} +1 -1
  137. package/dist/m/plugins/{source-YK7MO23W.js → source-7LSGOV46.js} +1 -1
  138. package/dist/m/plugins/{undo-VLRBQNPN.js → undo-UES4KS7B.js} +1 -1
  139. package/dist/m/plugins/{upgrade-NNTJBSK2.js → upgrade-MMPZUCDM.js} +1 -1
  140. package/dist/m/plugins/{upload-ZKKJV76X.js → upload-NZET3DNQ.js} +1 -1
  141. package/dist/m/plugins/visitor-drafts-V5YB7XDH.js +20 -0
  142. package/dist/m/plugins/{visitor-drafts-owner-LHUHFU3L.js → visitor-drafts-owner-TLECTYWS.js} +1 -1
  143. package/dist/m/plugins/{visitor-menu-FY4KM2SW.js → visitor-menu-QDV2MYJN.js} +1 -1
  144. package/dist/m/plugins/wire-3YGUO3GE.js +1 -0
  145. package/dist/m/{reader-core-T2S3JC4E.js → reader-core-VR7EL4WV.js} +2 -2
  146. package/dist/m/sync/live-sync-XMXXZS3G.js +1 -0
  147. package/dist/m/vendor/sap-core.vendor-JANUQULT.js +7 -0
  148. package/dist/offline-manifest.json +665 -635
  149. package/docs/guide.md +1 -0
  150. package/entries/sap.js +6 -5
  151. package/package.json +1 -1
  152. package/src/core/draft-mode.js +5 -0
  153. package/src/core/save-core.js +10 -10
  154. package/src/core/save.js +1 -1
  155. package/src/lib/dirty-gate.js +19 -0
  156. package/src/plugins/menu/visitor.js +33 -1
  157. package/src/plugins/visitor-drafts/commit.js +50 -3
  158. package/src/plugins/visitor-drafts/idb.js +8 -2
  159. package/src/plugins/visitor-drafts/recovery-menu.js +118 -0
  160. package/src/plugins/visitor-drafts/recovery.js +64 -0
  161. package/src/plugins/visitor-drafts/session.js +61 -13
  162. package/src/plugins/visitor-drafts/store.js +44 -8
  163. package/src/plugins/visitor-drafts.js +34 -5
  164. package/src/sync/live-sync.js +1 -1
  165. package/src/sync/wire-profile.js +9 -4
  166. package/src/vendor/sap-core.vendor.js +6 -5
  167. package/src/vendor/sap-ownership.vendor.js +181 -11
  168. package/tool-generation/system.md +1 -1
  169. package/dist/m/attrs/save-freeze-JZHKRQLR.js +0 -1
  170. package/dist/m/c-6Q6RGFBF.js +0 -1
  171. package/dist/m/c-APGHGDZM.js +0 -1
  172. package/dist/m/c-EILLN2H2.js +0 -1
  173. package/dist/m/c-HTLYXCUH.js +0 -1
  174. package/dist/m/c-MFOSMAVM.js +0 -3
  175. package/dist/m/c-R23UHMXG.js +0 -1
  176. package/dist/m/c-SGH3UTF3.js +0 -1
  177. package/dist/m/c-U4NTUVDZ.js +0 -1
  178. package/dist/m/c-V2U3F4QX.js +0 -1
  179. package/dist/m/c-VG5XIN4T.js +0 -1
  180. package/dist/m/c-VHSMPITY.js +0 -1
  181. package/dist/m/core/draft-mode-4MCL3LYI.js +0 -1
  182. package/dist/m/core/unsaved-warning-ITZDIZ2I.js +0 -1
  183. package/dist/m/internals/index-HS3CQDFW.js +0 -1
  184. package/dist/m/manifest-50FF1C45.js +0 -1
  185. package/dist/m/manifest-edit-BE1A621D.js +0 -1
  186. package/dist/m/plugins/visitor-drafts-RTZNONHT.js +0 -20
  187. package/dist/m/plugins/wire-M2NR354P.js +0 -1
  188. package/dist/m/sync/live-sync-Z64YREWQ.js +0 -1
  189. package/dist/m/vendor/sap-core.vendor-QAEPMETX.js +0 -6
  190. /package/dist/m/plugins/{richclay-Y2NV44NA.js.LEGAL.txt → richclay-L3BZMX4I.js.LEGAL.txt} +0 -0
@@ -29,6 +29,7 @@ import Mutation from './mutation.js';
29
29
  import { isEditMode } from '../core/is-edit-mode.js';
30
30
  import { STRIP_FROM_DIRTY_CHECK, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
31
31
  import { PERSIST } from './attr-aliases.js';
32
+ import { setDraftGate } from '../core/draft-mode.js';
32
33
 
33
34
  let changes = 0;
34
35
  let clearedAt = 0;
@@ -121,6 +122,7 @@ export function startDirtyGate() {
121
122
  );
122
123
  document.addEventListener('input', onUserInput, true);
123
124
  document.addEventListener('change', onUserInput, true);
125
+ setDraftGate({ token: gateTokenNow, unchangedSince: gateUnchangedSince });
124
126
  }
125
127
 
126
128
  /** The morph-apply window: nothing that happens inside it is a user edit. */
@@ -210,6 +212,23 @@ export function gateCaptureToken() {
210
212
  return { gen: changes, probe };
211
213
  }
212
214
 
215
+ /** A token for the page as it is now: what the browser has queued is counted first. */
216
+ export function gateTokenNow() {
217
+ Mutation._deliverBeforePause();
218
+ return gateCaptureToken();
219
+ }
220
+
221
+ /**
222
+ * True when nothing was recorded since the token was taken and every [persist] control
223
+ * still holds what it held then. Records the browser has queued are delivered first, so
224
+ * the answer holds in the task that made a change too.
225
+ */
226
+ export function gateUnchangedSince(token) {
227
+ if (!token) return false;
228
+ Mutation._deliverBeforePause();
229
+ return changes === token.gen && token.probe.every(([el, sig]) => controlSignature(el) === sig);
230
+ }
231
+
213
232
  /** A change nothing in the DOM recorded: the page holds text a save has not written. */
214
233
  export function gateMarkDirty() {
215
234
  changes++;
@@ -72,17 +72,49 @@ function copyUrl(loc = window.location) {
72
72
  return `/_/copy${path === "/" ? "" : path}`;
73
73
  }
74
74
 
75
+ // Hyperclay's trial bar, once the visitor closes it, leaves its Host action and a way back to the
76
+ // page (hyperclay server-lib/drop/trial-bar.js): `window.hyperclayBar` holds its state, set before
77
+ // this menu loads, and `hyperclay:bar` reports each change. Download copy is already in the menu.
78
+ function attachBar({ addItem, removeItem }) {
79
+ const draw = (bar) => {
80
+ if (bar?.kind !== "trial" || !bar.dismissed) {
81
+ removeItem("clay.bar.host");
82
+ removeItem("clay.bar.show");
83
+ return;
84
+ }
85
+ const host = Array.isArray(bar.actions) ? bar.actions.find((action) => action?.id === "host") : null;
86
+ if (typeof host?.href === "string") addItem({ id: "clay.bar.host", label: "Host and share online", href: host.href });
87
+ if (typeof bar.show === "function") addItem({ id: "clay.bar.show", label: "Show draft bar", run: () => bar.show() });
88
+ };
89
+ document.addEventListener("hyperclay:bar", (event) => draw(event.detail));
90
+ draw(window.hyperclayBar);
91
+ }
92
+
75
93
  // A visitor's menu holds exactly Undo, Redo, Download copy and Make your own copy (David, Nov 11):
76
- // no theme row, no page items, no Start over.
94
+ // no theme row, no page items, no Start over. While Hyperclay's trial bar is closed it also holds
95
+ // Host and share online and Show draft bar (David, Nov 11, sheet hosts review), and Earlier
96
+ // drafts when this page has recovery copies (David, Nov 13).
77
97
  export function start() {
78
98
  if (document.querySelector('clay-editor-ui[data-clay-ui="menu"]')) return;
79
99
  const shell = createShell({ sections: [["clay.undo"], null], icon: GEAR, label: "Settings" });
80
100
  const { embedded, skin, addItem, removeItem, open, close, note, goo, beforeOpen } = shell;
81
101
  shell.mount(embedded ? [MENU_SAFETY, SHELL_BASE, skin ?? MENU_SKIN, MENU_TOUCH] : [MENU_SAFETY, SHELL_BASE, SHELL_FLOATING, MENU_SKIN, MENU_TOUCH]);
102
+ let recoveryMenu;
103
+ beforeOpen.push(() => {
104
+ if (typeof globalThis.indexedDB?.open !== "function" || !globalThis.IDBKeyRange) return;
105
+ recoveryMenu ??= import("../visitor-drafts/recovery-menu.js").then(async ({ attachRecoveryMenu }) => {
106
+ const { recoveryDrafts } = await import("../visitor-drafts.js");
107
+ attachRecoveryMenu(shell, recoveryDrafts);
108
+ }).catch((error) => {
109
+ recoveryMenu = null;
110
+ console.warn("visitor-menu: earlier drafts did not load", error);
111
+ });
112
+ });
82
113
  publishLooks();
83
114
  attachUndo({ addItem, beforeOpen });
84
115
  addItem({ id: "clay.download", label: "Download copy", run: () => window.clay.visitorDrafts.download() });
85
116
  addItem({ id: "clay.copy", label: "Make your own copy", href: copyUrl() });
117
+ attachBar({ addItem, removeItem });
86
118
  window.clay.menu = { addItem, removeItem, open, close, note, embedded, button: goo };
87
119
  document.dispatchEvent(new CustomEvent("clay:menu-ready"));
88
120
  }
@@ -76,6 +76,22 @@ export function applyKeys(data, keys, merge3) {
76
76
  export const isChain = (value) => isPlain(value) && typeof value.tab === "string" && typeof value.reset === "string" && Array.isArray(value.ops);
77
77
  export const recoveryMark = (copy) => isChain(copy) ? `op:${copy.tab}:${copy.ops.at(-1)?.seq ?? copy.acked ?? 0}` : foreignMark(copy);
78
78
 
79
+ // Ended journals carry only the fields they changed; other fields use the served page.
80
+ export function recoveryRecord(copy, seed) {
81
+ if (isPlain(copy) && copy.v === 1 && isPlain(copy.data)) return clone(copy);
82
+ if (!isChain(copy) || !copy.ops.length) return null;
83
+ const data = clone(seed);
84
+ let unit = null;
85
+ for (const op of copy.ops) {
86
+ for (const [name, pair] of Object.entries(op.keys ?? {})) {
87
+ if ("m" in pair) Object.defineProperty(data, name, { value: clone(pair.m), enumerable: true, writable: true, configurable: true });
88
+ else delete data[name];
89
+ }
90
+ if (op.unit !== undefined) unit = op.unit.to;
91
+ }
92
+ return { v: 1, savedAt: copy.ops.at(-1).savedAt, data, ...(unit && { live: clone(unit.live), blockeditor: clone(unit.blockeditor) }) };
93
+ }
94
+
79
95
  // Cleanup needs proof for this exact chain, even when its generation has ended.
80
96
  export function settled(chain, meta) {
81
97
  if (isPlain(chain) && meta.recovery?.includes(recoveryMark(chain))) return true;
@@ -123,7 +139,7 @@ function foreignBase(line, { record, seen, direct, v1base, meta }) {
123
139
  * @param {?Object} [input.seen] - the latest cache copy the caller saw at the draft's key
124
140
  * @param {boolean} [input.direct] - a storage event says `local` replaced exactly that copy
125
141
  */
126
- export function commit(stored, { seed, newId, now, merge3, unitMark, marker = null, resets = [], chains = [], local = null, seen = null, direct = false }) {
142
+ export function commit(stored, { seed, newId, now, merge3, same, unitMark, marker = null, resets = [], chains = [], local = null, seen = null, direct = false, recovery = null, copies = [] }) {
127
143
  const meta = stored.meta ? clone(stored.meta) : emptyMeta(marker);
128
144
  // A database the browser cleared counts on from this ClayJS's cache, so the cache never
129
145
  // reads as newer than the record.
@@ -260,6 +276,36 @@ export function commit(stored, { seed, newId, now, merge3, unitMark, marker = nu
260
276
  out.applied[chain.tab] = at;
261
277
  }
262
278
 
279
+ if (recovery) {
280
+ const found = copies.find(([mark]) => mark === recovery.mark);
281
+ if (!found) throw new Error("That draft is no longer available. Open the list again.");
282
+ if (recovery.type === "remove") {
283
+ meta.recovery = (meta.recovery ?? []).filter((mark) => mark !== recovery.mark);
284
+ out.dropped.push(recovery.mark);
285
+ out.removedMark = recovery.mark;
286
+ out.changed = true;
287
+ } else if (recovery.type === "restore" && recovery.reset === meta.reset) {
288
+ const chosen = recoveryRecord(found[1], seed);
289
+ if (!chosen) throw new Error("This draft cannot be restored by this version.");
290
+ if (meta.recovery?.includes(recovery.mark)) meta.recovery = [recovery.mark, ...meta.recovery.filter((mark) => mark !== recovery.mark)];
291
+ const alreadyKept = record && copies.some(([mark, copy]) => {
292
+ if (!meta.recovery?.includes(mark)) return false;
293
+ const kept = recoveryRecord(copy, seed);
294
+ return kept && same(record.data, kept.data) && same(unitOf(record), unitOf(kept));
295
+ });
296
+ if (record && !alreadyKept) recover(record);
297
+ record = {
298
+ v: 1, savedAt: new Date(now).toISOString(), id: newId(),
299
+ ancestry: lineage([], record, meta), data: chosen.data,
300
+ ...(chosen.blockeditor && { live: chosen.live, blockeditor: chosen.blockeditor }),
301
+ };
302
+ meta.unit = unitMark(unitOf(record));
303
+ meta.rev++;
304
+ out.changed = true;
305
+ out.restoredCopy = true;
306
+ }
307
+ }
308
+
263
309
  for (const [tab, entry] of Object.entries(meta.tabs)) {
264
310
  if (now - (entry.at ?? 0) > TAB_TTL_MS) {
265
311
  delete meta.tabs[tab];
@@ -285,7 +331,8 @@ export function transact(drafts, key, input, options) {
285
331
  const stored = { meta: parse(raw.meta), record: parse(raw.record), v1base: parse(raw.v1base) };
286
332
  const rows = raw.rows.map(([name, text]) => [name, parse(text)]);
287
333
  const given = input(stored);
288
- const result = commit(stored, { ...given, chains: [...rows.map(([, chain]) => chain), ...(given.chains ?? [])] });
334
+ const copies = (raw.recovery ?? []).map(([name, text]) => [name.slice((key + ":recovery:").length), parse(text)]);
335
+ const result = commit(stored, { ...given, copies, chains: [...rows.map(([, chain]) => chain), ...(given.chains ?? [])] });
289
336
  const puts = [];
290
337
  const deletes = [];
291
338
  if (result.changed) {
@@ -297,7 +344,7 @@ export function transact(drafts, key, input, options) {
297
344
  if (result.conflicts.length) puts.push([conflictKey(key), JSON.stringify(result.conflicts[result.conflicts.length - 1])]);
298
345
  for (const copy of result.recovered) puts.push([recoveryKey(key, recoveryMark(copy)), JSON.stringify(copy)]);
299
346
  for (const mark of result.dropped) deletes.push(recoveryKey(key, mark));
300
- for (const [name, chain] of rows) if (settled(chain, result.meta)) deletes.push(name);
347
+ for (const [name, chain] of rows) if (settled(chain, result.meta) || (result.removedMark && isChain(chain) && recoveryMark(chain) === result.removedMark)) deletes.push(name);
301
348
  return { puts, deletes, result };
302
349
  }, options);
303
350
  }
@@ -138,7 +138,7 @@ function wrap(db, keyRange) {
138
138
  // `key`, hands them to `build` inside the transaction, and writes the `puts` and
139
139
  // `deletes` it returns before the transaction can complete. A `build` or a write that
140
140
  // throws aborts it, so nothing lands. Resolves with `build`'s `result` on `complete`.
141
- function change(key, build, { timeout = 0 } = {}) {
141
+ function change(key, build, { timeout = 0, recovery = false } = {}) {
142
142
  return new Promise((resolve, reject) => {
143
143
  let tx;
144
144
  try {
@@ -167,9 +167,14 @@ function wrap(db, keyRange) {
167
167
  const store = tx.objectStore(RECORDS);
168
168
  const rows = keyRange.bound(key + ":op:", key + ":op:\uffff");
169
169
  const reads = { record: store.get(key), meta: store.get(key + ":meta"), v1base: store.get(key + ":v1base"), names: store.getAllKeys(rows), values: store.getAll(rows) };
170
+ const copies = recovery ? keyRange.bound(key + ":recovery:", key + ":recovery:\uffff") : null;
171
+ if (copies) {
172
+ reads.copyNames = store.getAllKeys(copies);
173
+ reads.copyValues = store.getAll(copies);
174
+ }
170
175
  // Requests in one transaction answer in the order they were made: the last one in
171
176
  // means every read is.
172
- reads.values.onsuccess = () => {
177
+ (reads.copyValues ?? reads.values).onsuccess = () => {
173
178
  if (settled) return;
174
179
  try {
175
180
  const raw = {
@@ -177,6 +182,7 @@ function wrap(db, keyRange) {
177
182
  meta: reads.meta.result ?? null,
178
183
  v1base: reads.v1base.result ?? null,
179
184
  rows: reads.names.result.map((name, i) => [name, reads.values.result[i]]),
185
+ ...(copies && { recovery: reads.copyNames.result.map((name, i) => [name, reads.copyValues.result[i]]) }),
180
186
  };
181
187
  const planned = build(raw);
182
188
  for (const [name, value] of planned.puts) store.put(value, name);
@@ -0,0 +1,118 @@
1
+ import { COPY } from "./recovery.js";
2
+ import { bevelBox, bevelButton, bevelText, setShown } from "../../ui/bevel-controls.js";
3
+ import { FONT_MONO } from "../../ui/bevel.js";
4
+ import { resolveTheme } from "../../ui/theme.js";
5
+ import modal, { SURFACE_THEME } from "../../ui/modal.js";
6
+
7
+ export function attachRecoveryMenu(shell, load) {
8
+ let request = 0;
9
+ const refresh = async () => {
10
+ const id = ++request;
11
+ try {
12
+ const recovery = await load();
13
+ const rows = recovery ? await recovery.list() : [];
14
+ if (id !== request) return;
15
+ if (!rows.length) return shell.removeItem("clay.earlier-drafts");
16
+ shell.addItem({ id: "clay.earlier-drafts", label: COPY.label, run: () => openRecovery(recovery, { refresh, anchor: shell.goo }).catch(() => shell.note("The drafts could not be opened. Try again.")) });
17
+ } catch {
18
+ if (id === request) shell.removeItem("clay.earlier-drafts");
19
+ }
20
+ };
21
+ shell.beforeOpen.push(refresh);
22
+ refresh();
23
+ return refresh;
24
+ }
25
+
26
+ export async function openRecovery(recovery, { refresh = () => {}, anchor = document.activeElement } = {}) {
27
+ const theme = resolveTheme();
28
+ const box = bevelBox("div", ["display:flex", "flex-direction:column", "gap:16px"], { theme, parts: ["dialog.content"] });
29
+ box.dataset.draftCopies = "";
30
+ box.append(bevelText("p", ["display:block", "margin:0"], COPY.intro, { theme }));
31
+ const status = bevelText("p", ["display:block", "margin:0"], "", { theme });
32
+ status.setAttribute("role", "status");
33
+ const list = bevelBox("ol", ["display:flex", "flex-direction:column", "gap:16px", "padding:0", "margin:0", "list-style:none"], { theme });
34
+ box.append(status, list);
35
+ let busy = false;
36
+
37
+ async function draw() {
38
+ const rows = await recovery.list();
39
+ list.replaceChildren();
40
+ if (!rows.length) status.textContent = "No earlier drafts are kept.";
41
+ for (const row of rows) {
42
+ const item = bevelBox("li", ["display:flex", "flex-direction:column", "gap:8px"], { theme });
43
+ item.dataset.draftMark = row.mark;
44
+ const date = new Date(row.savedAt);
45
+ const when = Number.isNaN(date.getTime()) ? "Time unavailable" : date.toLocaleString();
46
+ const time = bevelText("time", ["display:block", `font-family:${FONT_MONO}`], when, { theme });
47
+ if (row.savedAt) time.dateTime = row.savedAt;
48
+ item.append(time, bevelText("p", ["display:block", "margin:0"], `${row.fields} saved ${row.fields === 1 ? "field" : "fields"}`, { theme, role: "mutedText" }));
49
+ const actions = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px"], { theme });
50
+ const preview = bevelBox("div", ["display:block"], { theme });
51
+ setShown(preview, false);
52
+ const confirm = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px", "align-items:center"], { theme });
53
+ setShown(confirm, false);
54
+ const action = (label, fn, options = {}) => {
55
+ const button = bevelButton(label, { small: true, theme, ...options, onClick: async () => {
56
+ if (busy) return;
57
+ busy = true;
58
+ button.setDisabled(true);
59
+ status.textContent = "";
60
+ try { await fn(button); }
61
+ catch (error) { status.textContent = error.message || "That did not work. Try again."; }
62
+ finally { busy = false; button.setDisabled(false); }
63
+ } });
64
+ button.setAttribute("aria-label", `${label}: ${when}`);
65
+ return button;
66
+ };
67
+ const open = action("Preview", () => {
68
+ if (!preview.hidden) { setShown(preview, false); open.setAttribute("aria-expanded", "false"); return; }
69
+ const doc = new DOMParser().parseFromString(recovery.html(row.copy), "text/html");
70
+ for (const el of doc.querySelectorAll('script, meta[http-equiv="refresh" i]')) el.remove();
71
+ for (const el of doc.querySelectorAll("input, textarea, select, button")) el.disabled = true;
72
+ for (const el of doc.querySelectorAll("[contenteditable]")) el.removeAttribute("contenteditable");
73
+ for (const el of doc.querySelectorAll("a, area")) {
74
+ el.removeAttribute("href");
75
+ el.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
76
+ el.removeAttribute("xlink:href");
77
+ }
78
+ const policy = doc.createElement("meta");
79
+ policy.httpEquiv = "Content-Security-Policy";
80
+ policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; form-action 'none'";
81
+ doc.head.prepend(policy);
82
+ const frame = bevelBox("iframe", ["display:block", "width:100%", "height:45vh", "border:0"], { theme });
83
+ frame.title = `Draft preview: ${when}`;
84
+ frame.setAttribute("sandbox", "");
85
+ frame.srcdoc = "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
86
+ preview.replaceChildren(frame);
87
+ setShown(preview, true);
88
+ open.setAttribute("aria-expanded", "true");
89
+ });
90
+ open.setAttribute("aria-expanded", "false");
91
+ const restore = action("Restore", async () => { await recovery.restore(row.mark); status.textContent = "Draft restored. Reloading the page."; await refresh(); });
92
+ const download = action("Download", () => recovery.download(row.copy));
93
+ for (const button of [open, restore, download]) button.setDisabled(!row.available);
94
+ const remove = action("Remove", () => { setShown(confirm, true, "flex"); cancel.focus(); });
95
+ const cancel = bevelButton("Keep draft", { small: true, theme, onClick: () => { setShown(confirm, false); remove.focus(); } });
96
+ confirm.append(bevelText("p", ["margin:0"], "Remove this saved draft?", { theme }), cancel, action("Remove draft", async () => {
97
+ await recovery.remove(row.mark);
98
+ await draw();
99
+ await refresh();
100
+ list.querySelector("button")?.focus();
101
+ }, { variant: "danger" }));
102
+ actions.append(open, restore, download, remove);
103
+ item.append(actions, confirm, preview);
104
+ list.append(item);
105
+ }
106
+ }
107
+
108
+ await draw();
109
+ modal.title = COPY.label;
110
+ modal.html = box;
111
+ modal.width = "720px";
112
+ modal[SURFACE_THEME] = theme;
113
+ modal.closeHtml = "x";
114
+ modal.no = "Close";
115
+ modal.onNo(() => anchor?.focus());
116
+ modal.open();
117
+ return box;
118
+ }
@@ -0,0 +1,64 @@
1
+ import { applyData, extractData } from "../../vendor/hyper-html-api-data.vendor.js";
2
+ import { finalizeControlForSave } from "../../vendor/control-serialize.vendor.js";
3
+ import { downloadHtml, documentName } from "../../core/download.js";
4
+
5
+ export const COPY = {
6
+ label: "Earlier drafts",
7
+ intro: "This page kept your earlier drafts aside, listed by time.",
8
+ };
9
+
10
+ export function createRecovery({ store, session, getHTML = () => window.clay.getHTML(), download = downloadHtml }) {
11
+ const recordOf = (copy) => session.recoveryRecord(copy);
12
+ const savedAt = (copy) => copy?.savedAt ?? copy?.ops?.at(-1)?.savedAt ?? (copy?.at ? new Date(copy.at).toISOString() : null);
13
+
14
+ async function list() {
15
+ const rows = await session.listRecovery();
16
+ return rows.map(({ mark, copy }) => {
17
+ const record = recordOf(copy);
18
+ const fields = copy?.ops ? new Set(copy.ops.flatMap((op) => Object.keys(op.keys ?? {}))).size : Object.keys(record?.data ?? {}).length;
19
+ return { mark, copy, savedAt: savedAt(copy), fields, available: Boolean(record) };
20
+ }).sort((a, b) => (Date.parse(b.savedAt) || 0) - (Date.parse(a.savedAt) || 0));
21
+ }
22
+
23
+ function html(copy) {
24
+ const record = recordOf(copy);
25
+ if (!record) throw new Error("This draft cannot be opened by this version.");
26
+ const doc = new DOMParser().parseFromString(getHTML(), "text/html");
27
+ applyData(doc, store.seed(), store.rules);
28
+ applyData(doc, record.data, store.rules);
29
+ if (!store.fitsInto(record.data, extractData(doc, store.rules))) throw new Error("This draft no longer fits the page. Its saved copy is still kept.");
30
+ const unit = record.blockeditor ? record : store.servedUnit();
31
+ const article = doc.querySelector("article[data-blockeditor]");
32
+ if (unit) {
33
+ if (!article) throw new Error("This draft's page editor is no longer available.");
34
+ for (const tag of doc.querySelectorAll('script[type="application/json"][merge][clay~="live"]')) tag.remove();
35
+ for (const [name, value] of Object.entries(unit.live)) {
36
+ const tag = doc.createElement("script");
37
+ tag.type = "application/json";
38
+ tag.setAttribute("merge", name);
39
+ tag.setAttribute("clay", "live");
40
+ tag.textContent = JSON.stringify(value).replace(/</g, "\\u003c");
41
+ article.before(tag);
42
+ }
43
+ article.setAttribute("data-rev", unit.blockeditor.rev);
44
+ article.innerHTML = unit.blockeditor.html;
45
+ }
46
+ for (const control of doc.querySelectorAll('input:not([type="password"]):not([type="file"]), textarea, select')) finalizeControlForSave(control);
47
+ return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
48
+ }
49
+
50
+ async function downloadCopy(copy) {
51
+ const ok = await download({ html: html(copy), name: documentName() + ".htmlclay" });
52
+ if (!ok) throw new Error("The download could not be made. Try again.");
53
+ return true;
54
+ }
55
+
56
+ async function restore(mark) {
57
+ const row = (await list()).find((row) => row.mark === mark);
58
+ if (!row) throw new Error("That draft is no longer available. Open the list again.");
59
+ html(row.copy);
60
+ return session.restoreRecovery(mark);
61
+ }
62
+
63
+ return { list, html, download: downloadCopy, restore, remove: (mark) => session.removeRecovery(mark) };
64
+ }
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { openDrafts } from "./idb.js";
23
- import { emptyMeta, foreignMark, isForeign, markerFor, markerGen, recoveryKey, recoveryMark, rowKey, settled, transact } from "./commit.js";
23
+ import { emptyMeta, foreignMark, isChain, isForeign, markerFor, markerGen, recoveryKey, recoveryMark, recoveryRecord, rowKey, settled, transact } from "./commit.js";
24
24
  import { setDraftStoreFailed } from "../../core/draft-mode.js";
25
25
 
26
26
  export const FULL_COPY_LIMIT = 256 * 1024;
@@ -91,6 +91,7 @@ export async function openSession({ store, storage, doc = document, now = () =>
91
91
  let sent = null;
92
92
  let carried = 0;
93
93
  let retiring = false;
94
+ let restoringCopy = false;
94
95
  let shownId = store.held()?.id;
95
96
  // The latest full cache copy this tab saw at the key, and the text of another writer's
96
97
  // record a storage event says replaced it: such a record merges from that copy.
@@ -127,6 +128,7 @@ export async function openSession({ store, storage, doc = document, now = () =>
127
128
  seed: store.seed(),
128
129
  newId: store.newId,
129
130
  merge3: store.merge3,
131
+ same: store.same,
130
132
  unitMark: store.unitMark,
131
133
  now: now(),
132
134
  marker: held,
@@ -139,12 +141,12 @@ export async function openSession({ store, storage, doc = document, now = () =>
139
141
  }
140
142
 
141
143
  // Another tab's slot is removed once nothing more can come of it and its tab has gone quiet.
142
- function sweep(meta) {
144
+ function sweep(meta, removedMark) {
143
145
  const at = now();
144
146
  for (const name of store.journalSlots()) {
145
147
  if (name === store.slotKey) continue;
146
148
  const chain = parse(read(name));
147
- if (settled(chain, meta) && at - (chain?.at ?? 0) > SLOT_AGE_MS) remove(name);
149
+ if ((removedMark && isChain(chain) && recoveryMark(chain) === removedMark) || (settled(chain, meta) && at - (chain?.at ?? 0) > SLOT_AGE_MS)) remove(name);
148
150
  }
149
151
  }
150
152
 
@@ -169,20 +171,26 @@ export async function openSession({ store, storage, doc = document, now = () =>
169
171
  try { storage.setItem(key, headOf(record, w)); } catch {}
170
172
  }
171
173
 
174
+ async function stopWithPending() {
175
+ store.stop({ keepPending: true });
176
+ if (store.unsafe()) {
177
+ try { await drafts.putRow(rowKey(key, store.tab), JSON.stringify(store.chain())); }
178
+ catch (error) { throw store.failed(error); }
179
+ store.acknowledge(store.chain().ops.at(-1)?.seq ?? 0);
180
+ }
181
+ }
182
+
172
183
  // Operations captured after the transaction read stay journaled for the next load.
173
184
  async function ended(result) {
185
+ try { store.captureLate(); }
186
+ catch (error) { throw store.failed(error); }
174
187
  retiring = true;
175
188
  const chain = store.chain();
176
189
  const mark = recoveryMark(chain);
177
190
  const kept = chain.ops.some((op) => op.seq > carried) || result.meta.recovery?.includes(mark)
178
191
  ? mark : result.meta.recovery?.includes(shownId) ? shownId : null;
179
192
  store.acknowledge(carried);
180
- store.stop({ keepPending: true });
181
- if (store.unsafe()) {
182
- try { await drafts.putRow(rowKey(key, store.tab), JSON.stringify(store.chain())); }
183
- catch (error) { throw store.failed(error); }
184
- store.acknowledge(store.chain().ops.at(-1)?.seq ?? 0);
185
- }
193
+ await stopWithPending();
186
194
  cache(result);
187
195
  if (kept) rememberRecovery(kept);
188
196
  const held = store.held();
@@ -277,13 +285,14 @@ export async function openSession({ store, storage, doc = document, now = () =>
277
285
  gen = meta.gen;
278
286
  rememberGeneration(meta.reset);
279
287
  const saved = store.acknowledge(meta.tabs[store.tab]?.seq ?? 0);
280
- sweep(meta);
288
+ sweep(meta, result.removedMark);
281
289
  cache(result);
282
290
  if (store.pending()) return result;
283
291
  if (saved) {
284
292
  setDraftStoreFailed(false);
285
293
  doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data: record.data } }));
286
294
  }
295
+ if (restoringCopy) return result;
287
296
  if (!store.shown()) {
288
297
  rev = meta.rev;
289
298
  return result;
@@ -303,8 +312,15 @@ export async function openSession({ store, storage, doc = document, now = () =>
303
312
  // so it carries every operation made before then.
304
313
  let active = null;
305
314
  let waiting = null;
306
- function run(options) {
307
- active = transact(drafts, key, input, options).then(took, (error) => {
315
+ function run(options, recovery = null) {
316
+ const given = () => {
317
+ if (recovery?.type === "restore") {
318
+ if (store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
319
+ store.captureNow();
320
+ }
321
+ return { ...input(), recovery };
322
+ };
323
+ active = transact(drafts, key, given, options).then(took, (error) => {
308
324
  throw store.pending() ? store.failed(error) : error;
309
325
  }).finally(() => { active = null; });
310
326
  return active;
@@ -318,6 +334,38 @@ export async function openSession({ store, storage, doc = document, now = () =>
318
334
  });
319
335
  return waiting;
320
336
  }
337
+ async function changeRecovery(recovery) {
338
+ while (active || waiting) await (waiting ?? active).catch(() => {});
339
+ return run({ recovery: true }, recovery);
340
+ }
341
+
342
+ async function restoreRecovery(mark) {
343
+ if (restoringCopy) throw new Error("A draft is already being restored.");
344
+ restoringCopy = true;
345
+ try {
346
+ if (store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
347
+ store.captureNow();
348
+ let result;
349
+ do {
350
+ result = await changeRecovery({ type: "restore", mark, reset: store.generation() });
351
+ if (!result.restoredCopy || store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
352
+ } while (store.pending());
353
+ // This tab's pre-restore page is retired without ending the other tabs' generation.
354
+ try { store.captureLate("restored:" + store.tab); }
355
+ catch (error) { throw store.failed(error); }
356
+ await stopWithPending();
357
+ reload();
358
+ return result;
359
+ } finally { restoringCopy = false; }
360
+ }
361
+
362
+ async function listRecovery() {
363
+ await first.catch(() => {});
364
+ const prefix = key + ":recovery:";
365
+ const rows = await drafts.readAll(key);
366
+ return [...rows].filter(([name]) => name.startsWith(prefix)).map(([name, raw]) => ({ mark: name.slice(prefix.length), copy: parse(raw) }));
367
+ }
368
+
321
369
  const quietly = () => { sync().catch(() => {}); };
322
370
 
323
371
  // At a hide, an operation too large for the journal slot goes into the database blind, with
@@ -341,5 +389,5 @@ export async function openSession({ store, storage, doc = document, now = () =>
341
389
 
342
390
  const first = run({ timeout: FIRST_TRANSACTION_TIMEOUT_MS });
343
391
  first.catch(() => {});
344
- return { first, sync, hide, startOver };
392
+ return { first, sync, hide, startOver, listRecovery, restoreRecovery, recoveryRecord: (copy) => recoveryRecord(copy, store.seed()), removeRecovery: (mark) => changeRecovery({ type: "remove", mark }) };
345
393
  }
@@ -1,5 +1,5 @@
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;
@@ -73,6 +73,7 @@ export function same(a, b) {
73
73
 
74
74
  const STORE_SEL = 'script[type="application/json"][merge][clay~="live"]';
75
75
  const ARTICLE_SEL = "article[data-blockeditor]";
76
+ const HANDLER_SEL = "[onbeforesave], [onbeforesnapshot]";
76
77
  const isPagePart = (name) => name === "blockeditor" || /^blockeditor\.\d+$/.test(name);
77
78
  const canonical = (live) => JSON.stringify(Object.keys(live).sort().map((name) => [name, live[name]]));
78
79
 
@@ -269,7 +270,7 @@ function withoutSiteKey(value) {
269
270
  return rest;
270
271
  }
271
272
 
272
- export function createStore({ doc = document, storage = safeStorage(), now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
273
+ export function createStore({ doc = document, storage = safeStorage(), gate = draftGate, now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
273
274
  const slot = draftSlot(doc);
274
275
  const key = draftKey(slot, path);
275
276
  const rules = withoutSiteKey(engine.findRulesIn(doc, "api").rules);
@@ -610,16 +611,46 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
610
611
  // to `localStorage` after it made some.
611
612
  let before = null;
612
613
 
613
- function capture(html) {
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) {
614
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) {
615
649
  if (unparked != null && park(unparked, { keepSlot: true }) === null) {
616
650
  throw storageFailure(doc, new Error("an earlier saved copy could not be kept aside"));
617
651
  }
618
652
  // Too much is waiting: nothing more is taken until it drains, and the page keeps it.
619
653
  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
654
  const unitChanged = !sameUnit(mineUnit, editorUnit);
624
655
  const keys = {};
625
656
  for (const name of Object.keys(mine)) {
@@ -644,6 +675,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
644
675
  }
645
676
  if (unitChanged) editorUnit = seenUnit = mineUnit;
646
677
  fresh = false;
678
+ tab.reset = reset;
647
679
  tab.ops.push(op);
648
680
  tab.sizes.push(JSON.stringify(op).length);
649
681
  journal();
@@ -655,7 +687,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
655
687
  function keep() {
656
688
  if (stopped) return true;
657
689
  try {
658
- if (changed()) capture(window.clay.getHTML());
690
+ if (changed()) captureNow();
659
691
  } catch { return false; }
660
692
  return tab.journaled === tab.seq;
661
693
  }
@@ -680,6 +712,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
680
712
  // unsaved changes, for `write` to save.
681
713
  function release() {
682
714
  if (tab.ops.length) ({ base, editorUnit, seenUnit } = before);
715
+ capturedAt = null;
683
716
  drop();
684
717
  }
685
718
 
@@ -815,8 +848,11 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
815
848
 
816
849
  return {
817
850
  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,
851
+ tab: tab.id, slotKey, chain, capture, captureNow, captureLate, keep, acknowledge, release, take, stop, journalSlots,
819
852
  seed: seedData,
853
+ servedUnit: () => { readServed(); return served; },
854
+ fitsInto,
855
+ same,
820
856
  // For the session, which is fetched after the boot and imports nothing from here.
821
857
  merge3,
822
858
  unitMark,