@panphora/clayjs 1.25.0 → 1.26.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 (250) 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 +5570 -4173
  11. package/dist/m/attrs/{save-freeze-MH3AOD56.js → save-freeze-PUYX32AK.js} +1 -1
  12. package/dist/m/{c-BOMQIQ3V.js → c-2EFMJYGJ.js} +1 -1
  13. package/dist/m/c-2EPHRF47.js +1 -0
  14. package/dist/m/{c-JD2VFAYG.js → c-2SUFMUZX.js} +2 -2
  15. package/dist/m/{c-CITLTVQ4.js → c-2TJCFPX4.js} +1 -1
  16. package/dist/m/c-33SS5A4Y.js +1 -0
  17. package/dist/m/{c-V2FPBRO6.js → c-34J5HC7N.js} +1 -1
  18. package/dist/m/c-3F3AADKG.js +1 -0
  19. package/dist/m/{c-TCFVG3I4.js → c-3S3647J6.js} +1 -1
  20. package/dist/m/{c-GY4BREDT.js → c-3S5TNRZA.js} +1 -1
  21. package/dist/m/{c-GDNZEOOZ.js → c-3WX5FABT.js} +1 -1
  22. package/dist/m/{c-AI3H6ETU.js → c-3XGLJAS7.js} +2 -2
  23. package/dist/m/c-42WBOKFM.js +1 -0
  24. package/dist/m/c-47M4QHCK.js +1 -0
  25. package/dist/m/{c-UOUUI265.js → c-4M2T676V.js} +1 -1
  26. package/dist/m/{c-GEAG5XAK.js → c-4TP3UXBT.js} +1 -1
  27. package/dist/m/{c-IESDKFO6.js → c-5ELELG2F.js} +1 -1
  28. package/dist/m/{c-X3V4RWMA.js → c-5ZYLTN3Q.js} +1 -1
  29. package/dist/m/{c-5PZYDWTW.js → c-67TBU56I.js} +1 -1
  30. package/dist/m/{c-WNOLMHI2.js → c-6BGJAJ67.js} +1 -1
  31. package/dist/m/{c-4Q6QIODW.js → c-755ZOHOM.js} +1 -1
  32. package/dist/m/c-7Q4XYDF4.js +1 -0
  33. package/dist/m/c-7RHOMAMR.js +1 -0
  34. package/dist/m/c-A5LASUEZ.js +1 -0
  35. package/dist/m/{c-IL5IVTOG.js → c-ABOBD7F3.js} +1 -1
  36. package/dist/m/c-AQVQKFKB.js +1 -0
  37. package/dist/m/{c-QDVG4NAZ.js → c-ARO6CPMA.js} +1 -1
  38. package/dist/m/{c-ORLPQFNU.js → c-AZM34SPO.js} +1 -1
  39. package/dist/m/{c-XUZMRQ7O.js → c-BCS3AZQZ.js} +1 -1
  40. package/dist/m/c-BOS7GONO.js +1 -0
  41. package/dist/m/{c-SUAC5RSG.js → c-C5UGNOC2.js} +1 -1
  42. package/dist/m/c-C6DXZIXD.js +1 -0
  43. package/dist/m/{c-WSTUCBHV.js → c-CPRGR2II.js} +1 -1
  44. package/dist/m/{c-NICICT4P.js → c-DEYHCYJB.js} +1 -1
  45. package/dist/m/{c-WGQVSKKC.js → c-DYTBSJBI.js} +1 -1
  46. package/dist/m/{c-DT7E76Z2.js → c-DZT6W2NG.js} +1 -1
  47. package/dist/m/{c-O2QZDVZ2.js → c-EEWPNUM2.js} +1 -1
  48. package/dist/m/{c-MFB74XVN.js → c-EHAR4RJJ.js} +2 -2
  49. package/dist/m/c-EHJWIGWN.js +1 -0
  50. package/dist/m/c-EIDZHQHZ.js +1 -0
  51. package/dist/m/{c-V2V5FKXX.js → c-EIUKME3W.js} +1 -1
  52. package/dist/m/c-ELXMSEN5.js +1 -0
  53. package/dist/m/c-EXZOA6XY.js +7 -0
  54. package/dist/m/{c-FKTALXNV.js → c-EYFMAR3M.js} +1 -1
  55. package/dist/m/c-FC5RUFPR.js +1 -0
  56. package/dist/m/{c-FBOO2UY3.js → c-FUN6AKFE.js} +1 -1
  57. package/dist/m/{c-V4B3RUWB.js → c-GFASNR55.js} +1 -1
  58. package/dist/m/{c-XMIDHEKF.js → c-GY36NO7C.js} +1 -1
  59. package/dist/m/{c-UD4VDBGJ.js → c-H3M6UDNV.js} +1 -1
  60. package/dist/m/{c-SFZRY6RT.js → c-H47C6PJZ.js} +1 -1
  61. package/dist/m/{c-MUM53J5J.js → c-H4M2T4A6.js} +1 -1
  62. package/dist/m/{c-U7F66I64.js → c-HYNM5U6B.js} +1 -1
  63. package/dist/m/c-J2YYLEVM.js +1 -0
  64. package/dist/m/c-J2ZW4HMR.js +1 -0
  65. package/dist/m/{c-WCG6AKA7.js → c-JC4RZW4E.js} +1 -1
  66. package/dist/m/c-K2ZHQMPF.js +1 -0
  67. package/dist/m/c-KD2ME5UN.js +2 -0
  68. package/dist/m/{c-Z2OZFX3N.js → c-L6KVXPBO.js} +1 -1
  69. package/dist/m/{c-GO635CRU.js → c-LAKBBJOU.js} +2 -2
  70. package/dist/m/{c-4AE2A2LK.js → c-LETPPG2F.js} +1 -1
  71. package/dist/m/{c-SFYTKRU7.js → c-MKAUC6DB.js} +1 -1
  72. package/dist/m/{c-WDFQ7PBU.js → c-MMEVDN6D.js} +1 -1
  73. package/dist/m/{c-LS7IVQVW.js → c-MMV5JULA.js} +1 -1
  74. package/dist/m/{c-NEQRFC4O.js → c-MVVEIJUU.js} +1 -1
  75. package/dist/m/{c-SXZYVXD6.js → c-N362HNGO.js} +1 -1
  76. package/dist/m/{c-7K4SCQB7.js → c-ND3W7D5U.js} +1 -1
  77. package/dist/m/{c-3E2IK5HU.js → c-NNHNXD6M.js} +1 -1
  78. package/dist/m/{c-D62HRSZK.js → c-NYJ577NL.js} +1 -1
  79. package/dist/m/{c-YFO6ZJLQ.js → c-O3DPFZCE.js} +1 -1
  80. package/dist/m/c-OZOACVUD.js +1 -0
  81. package/dist/m/{c-MV7TEUTD.js → c-OZVK3TFI.js} +1 -1
  82. package/dist/m/{c-ZEA3DCG3.js → c-PWILUN4T.js} +1 -1
  83. package/dist/m/{c-BCWYEG6P.js → c-QA3Q3MDI.js} +1 -1
  84. package/dist/m/{c-XHZIKRCV.js → c-QKOKMQXA.js} +1 -1
  85. package/dist/m/{c-TGIMBAWE.js → c-RBWVF4N5.js} +1 -1
  86. package/dist/m/c-RES2S5Q3.js +1 -0
  87. package/dist/m/c-RFGELYEU.js +2 -0
  88. package/dist/m/c-RGFPLQGV.js +1 -0
  89. package/dist/m/{c-RGAALCDU.js → c-SPOEDIMP.js} +1 -1
  90. package/dist/m/{c-YX2MC7YL.js → c-SRW5OBCF.js} +1 -1
  91. package/dist/m/{c-C6YZNU6E.js → c-SYOA4T7V.js} +1 -1
  92. package/dist/m/c-SYVF6ZMF.js +1 -0
  93. package/dist/m/{c-NADLYVAH.js → c-TJ5PFOJ2.js} +1 -1
  94. package/dist/m/{c-KYTVZT4D.js → c-TKV273YU.js} +1 -1
  95. package/dist/m/c-U35T5NAY.js +1 -0
  96. package/dist/m/{c-PRZNJUYZ.js → c-UEKJWXCB.js} +2 -2
  97. package/dist/m/c-UW6MMZ4V.js +1 -0
  98. package/dist/m/{c-XQTERKQA.js → c-V4DBV5ZV.js} +1 -1
  99. package/dist/m/c-VGG3UR7I.js +1 -0
  100. package/dist/m/c-VU4WEWPH.js +4 -0
  101. package/dist/m/{c-5Q5XBN56.js → c-W7LWCJHD.js} +1 -1
  102. package/dist/m/c-WHMZCRQD.js +6 -0
  103. package/dist/m/{c-WMRJKQ2V.js → c-WHQUZM2V.js} +1 -1
  104. package/dist/m/c-WOJ2RTV4.js +1 -0
  105. package/dist/m/{c-ZJG6C2WL.js → c-WOWGGNCJ.js} +1 -1
  106. package/dist/m/{c-ZORVIXUR.js → c-XHD7XJTE.js} +1 -1
  107. package/dist/m/c-XONFK3WC.js +1 -0
  108. package/dist/m/{c-HGG7M2S3.js → c-Y3J5LDKB.js} +1 -1
  109. package/dist/m/{c-JB5YU57S.js → c-Y3MPVURA.js} +1 -1
  110. package/dist/m/c-Y6HH7LTM.js +1 -0
  111. package/dist/m/{c-52OC3OH3.js → c-YRDKVEGR.js} +1 -1
  112. package/dist/m/c-YYIN4NAF.js +30 -0
  113. package/dist/m/{c-WVCT3ITX.js → c-YZGWSCMT.js} +1 -1
  114. package/dist/m/c-ZFAPQQIB.js +1 -0
  115. package/dist/m/core/{admin-attrs-ZI6K6UGM.js → admin-attrs-43JBA6JB.js} +1 -1
  116. package/dist/m/core/{autosave-BSM2ABJL.js → autosave-HD7Y7UNV.js} +1 -1
  117. package/dist/m/core/{conflict-notice-7EQLDYPA.js → conflict-notice-UNDOD4BL.js} +1 -1
  118. package/dist/m/core/{persist-2SZO3PCE.js → persist-MX3VYVBN.js} +1 -1
  119. package/dist/m/core/{save-K6Q47J2X.js → save-JZ7R4ET2.js} +1 -1
  120. package/dist/m/core/{save-core-U3Z44XBO.js → save-core-I4N6S4ED.js} +1 -1
  121. package/dist/m/core/site-edits-C22O53GS.js +1 -0
  122. package/dist/m/core/{snapshot-PWCHYFL6.js → snapshot-N5MKDGNI.js} +1 -1
  123. package/dist/m/core/{unsaved-warning-2WRZLJH5.js → unsaved-warning-BP6ZY3PG.js} +1 -1
  124. package/dist/m/internals/{index-FSLC4M7J.js → index-3LAVREAU.js} +1 -1
  125. package/dist/m/loader-DCKCZKY6.js +1 -0
  126. package/dist/m/manifest-7954CBE3.js +1 -0
  127. package/dist/m/manifest-edit-93AA79D7.js +1 -0
  128. package/dist/m/manifest.json +1240 -1179
  129. package/dist/m/page/{editor-PKSPJ2XJ.js → editor-UF3CIA6V.js} +5 -5
  130. package/dist/m/plugins/{ai-edit-3LG6MGS3.js → ai-edit-WDV2GLDU.js} +2 -2
  131. package/dist/m/plugins/{cms-T2HLI7JG.js → cms-KG6TCTR3.js} +9 -9
  132. package/dist/m/plugins/{derive-7DR5TUDH.js → derive-R4USHZ7P.js} +1 -1
  133. package/dist/m/plugins/{indicator-CTKX4EY7.js → indicator-OLPZ4WFI.js} +1 -1
  134. package/dist/m/plugins/{live-IFLL6YN5.js → live-DF5YS5AH.js} +1 -1
  135. package/dist/m/plugins/{menu-YJZ3JWBC.js → menu-ESEFQARM.js} +1 -1
  136. package/dist/m/plugins/page-GZSU77JS.js +2 -0
  137. package/dist/m/plugins/{page-view-NMCCU6MF.js → page-view-7MM3FEAR.js} +1 -1
  138. package/dist/m/plugins/{people-6WIWCZEG.js → people-EBWE337I.js} +1 -1
  139. package/dist/m/plugins/quickcrop-UHURKA2F.js +26 -0
  140. package/dist/m/plugins/richclay-MXH2GFAZ.js +549 -0
  141. package/dist/m/plugins/sortable-BAG3MSWS.js +1 -0
  142. package/dist/m/plugins/{source-7LSGOV46.js → source-OYHDXKTB.js} +1 -1
  143. package/dist/m/plugins/undo-KLHAGV75.js +1 -0
  144. package/dist/m/plugins/{upgrade-MMPZUCDM.js → upgrade-SZQJYEQQ.js} +1 -1
  145. package/dist/m/plugins/{visitor-drafts-V5YB7XDH.js → visitor-drafts-ZE6PCAFP.js} +2 -2
  146. package/dist/m/plugins/{visitor-menu-QDV2MYJN.js → visitor-menu-5XY57JQA.js} +1 -1
  147. package/dist/m/plugins/{wire-3YGUO3GE.js → wire-S5ATC4QI.js} +1 -1
  148. package/dist/m/reader-core-AHOAP5DH.js +77 -0
  149. package/dist/m/sync/{live-sync-XMXXZS3G.js → live-sync-RZQSDMZW.js} +1 -1
  150. package/dist/m/ui/index-G7ZJXNMG.js +1 -0
  151. package/dist/m/vendor/{sap-core.vendor-JANUQULT.js → sap-core.vendor-5TWOJYVM.js} +4 -4
  152. package/dist/offline-manifest.json +780 -750
  153. package/dist/server/core.cjs +4245 -2614
  154. package/dist/server/markdown.cjs +1 -0
  155. package/dist/server/markdown.mjs +7 -0
  156. package/docs/guide.md +26 -37
  157. package/entries/clay-data.js +1 -1
  158. package/entries/sap.js +5 -5
  159. package/package.json +6 -2
  160. package/packed-contract.json +3 -0
  161. package/src/attrs/save-freeze.js +1 -1
  162. package/src/core/admin-contenteditable.js +1 -1
  163. package/src/core/admin-inputs.js +1 -1
  164. package/src/core/admin-onclick.js +1 -1
  165. package/src/core/admin-resources.js +5 -2
  166. package/src/core/conflict-notice-ui.js +59 -34
  167. package/src/core/save.js +9 -9
  168. package/src/core/site-edits.js +1 -1
  169. package/src/core/snapshot.js +56 -43
  170. package/src/core/stale-host-notice.js +20 -7
  171. package/src/loader-logic.js +0 -13
  172. package/src/page/boot-pack.js +1 -0
  173. package/src/page/editor.js +10 -4
  174. package/src/page/history-intent.js +111 -4
  175. package/src/page/import-markdown.js +22 -8
  176. package/src/page/save-projection.js +1 -1
  177. package/src/page/ybind/bridge.js +14 -7
  178. package/src/page/ybind/moves.js +437 -3
  179. package/src/page/ybind/origins.js +1 -1
  180. package/src/page/ybind/recovery.js +1 -1
  181. package/src/plugins/page.js +48 -4
  182. package/src/plugins/quickcrop.js +128 -69
  183. package/src/plugins/richclay.js +136 -0
  184. package/src/plugins/sortable.js +25 -9
  185. package/src/plugins/undo.js +169 -7
  186. package/src/recovery/undo.js +14 -7
  187. package/src/server/markdown/index.js +23 -0
  188. package/src/sync/conflicts.js +3 -1
  189. package/src/sync/data-guard-notice.js +137 -18
  190. package/src/sync/live-sync.js +24 -0
  191. package/src/ui/bevel-controls.js +4 -1
  192. package/src/ui/dialogs.js +11 -7
  193. package/src/ui/modal.js +9 -7
  194. package/src/ui/toast.js +67 -32
  195. package/src/ui/vendor-skin.js +9 -4
  196. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  197. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  198. package/src/vendor/hyper-undo.vendor.js +1 -1
  199. package/src/vendor/hypercms.vendor.js +7 -7
  200. package/src/vendor/richclay.vendor.js +16 -16
  201. package/src/vendor/sap-core.vendor.js +4 -4
  202. package/tool-generation/context.md +69 -0
  203. package/tool-generation/system.md +1 -1
  204. package/dist/m/c-277QP5ET.js +0 -1
  205. package/dist/m/c-5SZ6KFR5.js +0 -1
  206. package/dist/m/c-CU33V6RW.js +0 -4
  207. package/dist/m/c-CVSWLX3X.js +0 -1
  208. package/dist/m/c-DXPOFLEJ.js +0 -1
  209. package/dist/m/c-EODTTNVA.js +0 -1
  210. package/dist/m/c-ESK4S67I.js +0 -1
  211. package/dist/m/c-FDWR3FL7.js +0 -1
  212. package/dist/m/c-FJDTDGDA.js +0 -1
  213. package/dist/m/c-FYDKYW6E.js +0 -1
  214. package/dist/m/c-HKSLOVU4.js +0 -1
  215. package/dist/m/c-IJHF4IO6.js +0 -1
  216. package/dist/m/c-IUL7PU4P.js +0 -1
  217. package/dist/m/c-JWW6BHWD.js +0 -6
  218. package/dist/m/c-K4M76VPM.js +0 -7
  219. package/dist/m/c-M7ITYKMF.js +0 -1
  220. package/dist/m/c-MR75YPRT.js +0 -1
  221. package/dist/m/c-NFQFV7OP.js +0 -2
  222. package/dist/m/c-NHXOBD6A.js +0 -1
  223. package/dist/m/c-NVU3DORS.js +0 -1
  224. package/dist/m/c-PNZIJFG7.js +0 -1
  225. package/dist/m/c-QWB53KRW.js +0 -1
  226. package/dist/m/c-RHPHMTF2.js +0 -2
  227. package/dist/m/c-RWJ3OSCP.js +0 -1
  228. package/dist/m/c-SEVOS4UE.js +0 -1
  229. package/dist/m/c-SGG6MUXW.js +0 -1
  230. package/dist/m/c-SXNSSHPP.js +0 -1
  231. package/dist/m/c-TFLRT5JX.js +0 -1
  232. package/dist/m/c-TLQINES7.js +0 -30
  233. package/dist/m/c-U65PREMC.js +0 -1
  234. package/dist/m/c-V5L6HWVL.js +0 -1
  235. package/dist/m/c-VMXCX52Q.js +0 -1
  236. package/dist/m/c-YYGUHSZR.js +0 -1
  237. package/dist/m/core/site-edits-CFMF6GHD.js +0 -1
  238. package/dist/m/loader-RC54UV7H.js +0 -1
  239. package/dist/m/manifest-3B84C829.js +0 -1
  240. package/dist/m/manifest-edit-1CB79AD4.js +0 -1
  241. package/dist/m/plugins/page-GTDPYGE4.js +0 -2
  242. package/dist/m/plugins/quickcrop-X5MRIBAP.js +0 -26
  243. package/dist/m/plugins/richclay-L3BZMX4I.js +0 -549
  244. package/dist/m/plugins/sortable-365SRRL3.js +0 -1
  245. package/dist/m/plugins/undo-UES4KS7B.js +0 -1
  246. package/dist/m/reader-core-VR7EL4WV.js +0 -77
  247. package/dist/m/ui/index-MGTWOW2C.js +0 -1
  248. /package/dist/m/{c-QTTJYJMA.js → c-FILLTJA7.js} +0 -0
  249. /package/dist/m/plugins/{quickcrop-X5MRIBAP.js.LEGAL.txt → quickcrop-UHURKA2F.js.LEGAL.txt} +0 -0
  250. /package/dist/m/plugins/{richclay-L3BZMX4I.js.LEGAL.txt → richclay-MXH2GFAZ.js.LEGAL.txt} +0 -0
@@ -11,6 +11,56 @@ import { PERSIST } from '../lib/attr-aliases.js'
11
11
  import { isGestureItem, isRuntimeAttribute } from '../lib/undo/capture-move.js'
12
12
  import { gateMarkDirty } from '../lib/dirty-gate.js'
13
13
 
14
+ // Where the caret goes after an undo or redo: at the text that changed.
15
+ //
16
+ // The engine's receipt carries a hint for it: `{ node, offset }` for a text run, and
17
+ // `{ root, textOffset }` for a field whose saved content is its text. The person goes on
18
+ // typing from there, so the field takes focus when they were already typing in a field.
19
+ // When they were not (a button, the dock's Undo on a phone), the field is only scrolled
20
+ // into view: focusing it would open the keyboard. A structural step carries no hint, so
21
+ // an item coming back moves neither the caret nor the focus.
22
+
23
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])'
24
+
25
+ // The text node an offset into the field's text lands on: count the field's text nodes
26
+ // until the offset is inside one. Past the end is the end of the last text node, and an
27
+ // empty field is the field itself.
28
+ function resolve(root, textOffset) {
29
+ let left = Number(textOffset) || 0
30
+ if (left < 0) left = 0
31
+ const doc = root.ownerDocument || document
32
+ const walker = doc.createTreeWalker(root, 4)
33
+ let last = null
34
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
35
+ if (left <= node.data.length) return { node, offset: left }
36
+ left -= node.data.length
37
+ last = node
38
+ }
39
+ return last ? { node: last, offset: last.data.length } : { node: root, offset: root.childNodes.length }
40
+ }
41
+
42
+ // The field the hint is inside. A field in a hidden section is neither scrolled to nor
43
+ // focused, and a node that is no longer in the page has nowhere to put a caret.
44
+ function hostOf(node) {
45
+ const element = node.nodeType === 1 ? node : node.parentElement
46
+ const host = element?.closest?.(EDITABLE)
47
+ if (!host || !host.isConnected) return null
48
+ if (typeof host.checkVisibility === 'function' && host.checkVisibility() === false) return null
49
+ return host
50
+ }
51
+
52
+ function placeCaret(hint) {
53
+ if (!hint) return
54
+ const at = hint.root ? resolve(hint.root, hint.textOffset) : hint
55
+ if (!at?.node) return
56
+ const host = hostOf(at.node)
57
+ if (!host) return
58
+ if (typeof host.scrollIntoView === 'function') host.scrollIntoView({ block: 'nearest', inline: 'nearest' })
59
+ if (!document.activeElement?.closest?.(EDITABLE)) return
60
+ if (document.activeElement !== host) host.focus({ preventScroll: true })
61
+ document.getSelection()?.collapse(at.node, at.offset)
62
+ }
63
+
14
64
  // Tab-local attributes (ephemeral state, route-active, root library state) are
15
65
  // never undone, and neither is what a drag's vendor writes on the item it holds:
16
66
  // `draggable` and `style` are the gesture's own bookkeeping, and a click that
@@ -20,6 +70,17 @@ import { gateMarkDirty } from '../lib/dirty-gate.js'
20
70
  // The page's own maintenance attributes, kept outside history by name (a card number a
21
71
  // repair rewrites, for example): saved like any attribute, never an edit to undo.
22
72
  const pageIgnored = new Set()
73
+ // What a structural undo step compares for a node whose live markup is not its saved
74
+ // markup. An editor that owns a field registers one function (`clay.undo.addDurableForm`):
75
+ // it answers a string for a node it owns and undefined for every other node.
76
+ const durableForms = new Set()
77
+ const durableFormOf = (node, host = null) => {
78
+ for (const form of durableForms) {
79
+ const own = form(node, host)
80
+ if (typeof own === 'string') return own
81
+ }
82
+ return undefined
83
+ }
23
84
  const withTabLocal = (opts = {}) => {
24
85
  const own = opts.ignoreAttribute
25
86
  return {
@@ -27,15 +88,18 @@ const withTabLocal = (opts = {}) => {
27
88
  ignoreAttribute: (name, target, host) => pageIgnored.has(name) || isTabLocalAttr(name, target) || isDerivedAttr(target, name, host) || isRuntimeAttribute(name, target) || (own ? own(name, target, host) : false),
28
89
  isProtected: opts.isProtected || keepsOwnHistory,
29
90
  ignoreNode: opts.ignoreNode || isGestureItem,
91
+ durableForm: opts.durableForm || durableFormOf,
30
92
  }
31
93
  }
32
94
 
33
- // Saved content that undo does not record (a richclay region, a no-undo block) has a
34
- // history undo cannot restore, so a parent's undo or redo must not erase it. Chrome
35
- // that is never saved (editor-ui) loses nothing and is not protected.
95
+ // Saved content that undo does not record (an authored no-undo block) has a history
96
+ // undo cannot restore, so a parent's undo or redo must not erase it. A node with a
97
+ // durable form (a mounted rich field) is certified by that form instead, and chrome
98
+ // that is never saved (editor-ui) loses nothing; neither is protected.
36
99
  function keepsOwnHistory(node) {
37
100
  const policy = window.clay?.region?.resolveRegionPolicy
38
101
  if (!policy || node.nodeType !== 1) return false
102
+ if (durableFormOf(node) !== undefined) return false
39
103
  const own = policy(node)
40
104
  if (own.undoable !== false || own.persist === 'none') return false
41
105
  const parent = node.parentElement
@@ -52,9 +116,12 @@ let recording = null
52
116
  const setRecording = undo.setRecording.bind(undo)
53
117
  undo.setRecording = (mode) => {
54
118
  recording = mode ?? null
119
+ document.dispatchEvent(new CustomEvent('clay:undo-recording', { detail: { mode: recording } }))
55
120
  return setRecording(mode)
56
121
  }
57
122
 
123
+ Object.defineProperty(undo, 'recordingMode', { get: () => recording, configurable: true })
124
+
58
125
  // An authored history action is a user edit, and it writes the DOM while a sync
59
126
  // frame's apply may hold both the mutation hub and the gate paused: neither feed
60
127
  // records it then, and the apply would take the write into its saved baseline.
@@ -73,6 +140,17 @@ undo.command = (label, fn) => {
73
140
  // Undo and redo mark it in afterReplay, which the engine calls on every path that
74
141
  // replays a step: the facade and the keyboard shortcut bound to the engine itself.
75
142
 
143
+ // One entry holding one step that another editor stands behind (the block editor's own
144
+ // Yjs stack item). It goes through the engine's own command, whose flush closes whatever
145
+ // the page was typing, so the order of the two histories is the order of the edits. The
146
+ // editor's step is not a document write of its own, so recording it never marks the page
147
+ // dirty; the editor's own dirty feed is the one that does.
148
+ undo.recordStep = (label, primitive) => command(label, () => undo.record(primitive))
149
+
150
+ // A step that stands for an editor's own undo item holds tokens, not text: its prop names
151
+ // it, and what a person wrote lives in the editor, not in a recovery copy.
152
+ const isBlockStep = (entry) => !!entry?.steps?.length && entry.steps.every((step) => step.kind === 'value' && /^blockeditor-step(?::|$)/.test(step.prop))
153
+
76
154
  // A commit inside clay.effect drains the paused hub's records, so the effect's
77
155
  // own writes reach autosave and re-trigger it. Warn once; the commit still runs.
78
156
  const commit = undo.commit.bind(undo)
@@ -131,8 +209,9 @@ function restoreControls() {
131
209
  // undo or redo never reaches the control restore.
132
210
  const afterReplay = (receipt) => {
133
211
  if (!receipt?.ok) return
134
- gateMarkDirty()
212
+ if (!isBlockStep(undo.describe(receipt.entryId))) gateMarkDirty()
135
213
  restoreControls()
214
+ if (receipt.caret && undo.pageHistory) placeCaret(receipt.caret)
136
215
  }
137
216
 
138
217
  // A refusal is announced for whoever presents it: the engine's receipt, once.
@@ -143,6 +222,17 @@ const refuse = ({ entryId, label, direction, code }) => {
143
222
  keepRefused({ ok: false, code, entryId, label, direction })
144
223
  }
145
224
 
225
+ // A step the engine can no longer replay is skipped instead of blocking the press: the
226
+ // person asked once and gets the next older step. Its recovery copy is made from the
227
+ // description the engine hands over, because the entry is already out of the history.
228
+ const skip = ({ entryId, label, direction, code, description }) => {
229
+ document.dispatchEvent(new CustomEvent('clay:undo-skipped', {
230
+ detail: { entryId, label, direction, code },
231
+ }))
232
+ if (isBlockStep(description)) return
233
+ keepRefused({ ok: false, code, entryId, label, direction, description })
234
+ }
235
+
146
236
  // A refused step is kept as recovery before a reload can take it away: the refusal is
147
237
  // announced synchronously above and the copy is made behind it, so a store that will
148
238
  // not answer can never hold up the receipt. The module is fetched on the first refusal
@@ -185,6 +275,11 @@ const withRecovery = (call, fallback) => (...args) =>
185
275
  // An app's kept copy is announced like a refused step's, so the review notice shows it
186
276
  // in this session instead of only after a reload.
187
277
  const keepApp = withRecovery('keep', { ok: false, code: 'unavailable' })
278
+ undo.addDurableForm = (form) => {
279
+ durableForms.add(form)
280
+ return () => { durableForms.delete(form) }
281
+ }
282
+
188
283
  undo.ignoreAttribute = (name) => {
189
284
  const key = String(name || '')
190
285
  if (!key) return () => {}
@@ -235,9 +330,48 @@ undo.reviewActionsFor = (record) => {
235
330
  // after every entry, replay, refusal, dismissal and clear, and when a session starts or
236
331
  // ends. `undo` and `redo` are the entries the next press would replay, with `refused`
237
332
  // set on the one that was just refused.
238
- const HISTORY_EVENTS = ['commit', 'undo', 'redo', 'refused', 'discard', 'clear']
333
+ const HISTORY_EVENTS = ['commit', 'undo', 'redo', 'refused', 'skipped', 'discard', 'clear']
334
+
335
+ // Whatever else owns the moment (a proposal preview, a dialog) holds the page history: while
336
+ // any hold is live the engine refuses a replay, so nothing behind the hold can be undone by a
337
+ // stray Cmd+Z, and the event reports neither direction as available.
338
+ const holds = new Set()
339
+
340
+ // The engine's own answer for each direction: a getter on the engine object itself, so it is
341
+ // read once here and called through, whatever else happens to the property.
342
+ const engineDirection = (name) => {
343
+ for (let owner = undo; owner; owner = Object.getPrototypeOf(owner)) {
344
+ const desc = Object.getOwnPropertyDescriptor(owner, name)
345
+ if (!desc) continue
346
+ if (typeof desc.get === 'function') return () => desc.get.call(undo)
347
+ return () => Boolean(desc.value.call(undo))
348
+ }
349
+ return () => false
350
+ }
351
+
352
+ const engineCanUndo = engineDirection('canUndo')
353
+ const engineCanRedo = engineDirection('canRedo')
354
+
355
+ // While a hold is live neither direction is available, so a dock button, the editor's own
356
+ // history and the event all answer the same thing.
357
+ Object.defineProperty(undo, 'canUndo', { get: () => holds.size === 0 && engineCanUndo(), configurable: true })
358
+ Object.defineProperty(undo, 'canRedo', { get: () => holds.size === 0 && engineCanRedo(), configurable: true })
359
+
360
+ undo.hold = () => {
361
+ const token = Symbol('hold')
362
+ holds.add(token)
363
+ announceChange()
364
+ let released = false
365
+ return () => {
366
+ if (released) return
367
+ released = true
368
+ holds.delete(token)
369
+ announceChange()
370
+ }
371
+ }
372
+
239
373
  const announceChange = () => {
240
- const live = !!session?.started
374
+ const live = !!session?.started && holds.size === 0
241
375
  document.dispatchEvent(new CustomEvent('clay:undo-change', {
242
376
  detail: {
243
377
  canUndo: live && undo.canUndo,
@@ -254,6 +388,22 @@ const announceChange = () => {
254
388
  // recovery service and outlive every session; nothing here reads, clears or rewrites them.
255
389
  let session = null
256
390
 
391
+ // There is one history in Edit and it is the page's: a live session is what puts a rich
392
+ // field's typing on it and gives Cmd+Z in any editable field to it. No attribute decides
393
+ // this, and a page that never loads this plugin never starts a session, so nothing about
394
+ // it changes.
395
+ Object.defineProperty(undo, 'pageHistory', {
396
+ get: () => !!session?.started,
397
+ configurable: true,
398
+ })
399
+
400
+ const announceSession = () => {
401
+ if (typeof document === 'undefined') return
402
+ document.dispatchEvent(new CustomEvent('clay:undo-session', {
403
+ detail: { started: !!session?.started, pageHistory: undo.pageHistory },
404
+ }))
405
+ }
406
+
257
407
  function ownership() {
258
408
  return { started: false, admitted: false, subscriptions: [] }
259
409
  }
@@ -283,18 +433,29 @@ function undoLease(record) {
283
433
  if (record.started) return
284
434
  record.started = true
285
435
  record.admitted = true
286
- undo.start(withTabLocal({ bindKeys: true, ...(recording ? { recording } : {}) }))
436
+ undo.start(withTabLocal({
437
+ bindKeys: true,
438
+ editableKeys: 'page',
439
+ ownKeysIn: ['.blockeditor-editor'],
440
+ typingBreak: 'sentence',
441
+ refused: 'skip',
442
+ holdReplay: () => holds.size > 0,
443
+ ...(recording ? { recording } : {}),
444
+ }))
287
445
  for (const type of ['undo', 'redo']) {
288
446
  undo.on(type, afterReplay)
289
447
  record.subscriptions.push({ type, handler: afterReplay })
290
448
  }
291
449
  undo.on('refused', refuse)
292
450
  record.subscriptions.push({ type: 'refused', handler: refuse })
451
+ undo.on('skipped', skip)
452
+ record.subscriptions.push({ type: 'skipped', handler: skip })
293
453
  for (const type of HISTORY_EVENTS) {
294
454
  undo.on(type, announceChange)
295
455
  record.subscriptions.push({ type, handler: announceChange })
296
456
  }
297
457
  announceChange()
458
+ announceSession()
298
459
  },
299
460
  quiesce() {
300
461
  if (!record.admitted) return
@@ -314,6 +475,7 @@ function undoLease(record) {
314
475
  for (const { type, handler } of record.subscriptions.splice(0)) undo.off(type, handler)
315
476
  undo.stop()
316
477
  announceChange()
478
+ announceSession()
317
479
  },
318
480
  }
319
481
  }
@@ -148,7 +148,9 @@ let keeping = Promise.resolve();
148
148
  * keeps it — and answers with the failure.
149
149
  *
150
150
  * @param {Object} engine - the started undo scope (`clay.undo`)
151
- * @param {{entryId: string, direction: string, code?: string}} receipt
151
+ * @param {{entryId: string, direction: string, code?: string, description?: ?Object}} receipt
152
+ * `description` is the engine's own `describe(entryId)` answer where the caller already has
153
+ * it, as a skipped step does: that entry is out of the history by the time it is kept.
152
154
  * @returns {Promise<{ok: boolean, code?: string}>}
153
155
  */
154
156
  export function keepRefusal(engine, receipt) {
@@ -160,11 +162,13 @@ export function keepRefusal(engine, receipt) {
160
162
  async function keepRefusalNow(engine, receipt) {
161
163
  const ch = channel();
162
164
  if (!ch) return { ok: false, code: 'unavailable' };
163
- let described = null;
164
- try {
165
- described = engine?.describe?.(receipt?.entryId) ?? null;
166
- } catch (_) {
167
- described = null;
165
+ let described = receipt?.description ?? null;
166
+ if (!described) {
167
+ try {
168
+ described = engine?.describe?.(receipt?.entryId) ?? null;
169
+ } catch (_) {
170
+ described = null;
171
+ }
168
172
  }
169
173
  if (!described) return { ok: false, code: 'unknown-entry' };
170
174
  const refused = described.refused && typeof described.refused === 'object' ? described.refused : {};
@@ -186,7 +190,10 @@ async function keepRefusalNow(engine, receipt) {
186
190
  try {
187
191
  staged = ch.stage({ key, kind: UNDO_KIND, baseEtag: representedEtag() ?? null, base: null, payload });
188
192
  } catch (err) {
189
- console.error('clayjs: the refused step could not be kept for a later reload; it is still in this tab\'s history', err);
193
+ const message = receipt?.description
194
+ ? 'clayjs: the skipped step could not be kept for a later reload; its text is lost when this tab does'
195
+ : 'clayjs: the refused step could not be kept for a later reload; it is still in this tab\'s history'
196
+ console.error(message, err)
190
197
  return { ok: false, code: 'error' };
191
198
  }
192
199
  const written = await ch.flush();
@@ -0,0 +1,23 @@
1
+ import { Marked } from "../../vendor/marked.vendor.js";
2
+ import { importMarkdownTokens, MarkdownImportError, MAX_BYTES } from "../../page/import-markdown.js";
3
+ import { fragmentCleaner, project, renderModel, revOf } from "../../page/projection.js";
4
+ import { documentTitle } from "../../page/document-title.js";
5
+
6
+ export { MarkdownImportError, MAX_BYTES };
7
+
8
+ /**
9
+ * Markdown text as a Block editor article: the inner HTML of `<article data-blockeditor>`,
10
+ * the `data-rev` that article carries, and the title a save would write. The same codec
11
+ * and projection the editor uses, so the editor opens the result as its own page.
12
+ * `document` is any DOM document (jsdom on a server); raw HTML blocks are cleaned with it.
13
+ * `resolveImage(src)` may return a replacement address for an image, or null; `resolveLink(href)`
14
+ * does the same for a link (a link to another local file is otherwise kept as text).
15
+ */
16
+ export function markdownArticle(text, { document, resolveImage = null, resolveLink = null } = {}) {
17
+ if (!document) throw new TypeError("markdownArticle needs a DOM document");
18
+ const tokens = new Marked().lexer(String(text ?? ""));
19
+ const { blocks, unsupported, refused } = importMarkdownTokens(tokens, { document, resolveImage, resolveLink });
20
+ const model = project(blocks);
21
+ const html = renderModel(model, { fallbackOf: () => "", clean: fragmentCleaner(document) });
22
+ return { html, rev: revOf(model), title: documentTitle(model), unsupported, refused };
23
+ }
@@ -62,8 +62,10 @@ function changed(detail) {
62
62
  // A report is a loss unless the engine says this tab's operation survived, or a
63
63
  // hook kept the incoming change off the page. One the engine could not map to the
64
64
  // live page ("missing-output") still replaced this tab's work: it shows, without
65
- // a place to point at.
65
+ // a place to point at. A region whose incoming version always wins (no-dirty,
66
+ // no-watch) held nothing to keep, so replacing it is never a loss.
66
67
  function isLoss(c) {
68
+ if (c.kind === 'structure' && c.detail === 'remote-wins') return false;
67
69
  const r = c.recovery;
68
70
  if (!r) return true;
69
71
  if (r.applied === false && r.unavailable === 'hook-veto') return false;
@@ -1,12 +1,24 @@
1
1
  import { saveToken } from "../core/host-attrs.js";
2
2
  import { storage } from "../lib/doc-storage.js";
3
+ import { isSaveInProgress } from "../core/save-core.js";
3
4
 
4
- const TITLE = "Saved data was overwritten";
5
+ const TITLE = "Some saved data was removed";
6
+ const TITLES = {
7
+ recent: "A change from outside this page removed something you typed",
8
+ "all-typed": "A change from outside this page removed everything you typed",
9
+ emptied: "A change from outside this page emptied this page's data",
10
+ unreadable: "A change from outside this page left its data unreadable",
11
+ };
12
+ const TYPED_SHOWN = 3;
13
+ const HOUR_MS = 3600000;
5
14
  const DONE_KEY = "data-guard-done";
6
15
  const READ_TIMEOUT_MS = 6000;
7
16
  const AFTER_SAVE_MS = 1500;
8
17
  const MIN_READ_GAP_MS = 5000;
9
18
  const VISIBLE_GAP_MS = 30000;
19
+ const EXTERNAL_WINDOW_MS = 10000;
20
+ const SETTLE_CAP_MS = 6000;
21
+ const SAVE_UNSETTLED_EVENTS = ["clay:save-error", "clay:save-offline", "clay:save-conflict"];
10
22
  const UNREACHED = "Could not reach the server. Try again.";
11
23
  const REFUSED = "That did not go through. Try again.";
12
24
  const SAVE_FIRST = "Save your changes first, then try again.";
@@ -20,7 +32,15 @@ const isPending = (event) => Boolean(event) && typeof event.id === "string" && e
20
32
  const allowed = (current, event, choice, flag) => flag === true && !current.refused.has(`${event.id}:${choice}`);
21
33
  const canRestore = (current, event) => allowed(current, event, "restore", event.restorable);
22
34
  const canRevert = (current, event) => allowed(current, event, "revert", event.canRevert);
35
+ const canPutBack = (current, event) => allowed(current, event, "put-back", event.canPutBack);
36
+ const typed = (event) => (Array.isArray(event.typed) ? event.typed : []);
37
+ const reasoned = (event) => Boolean(event.reason);
38
+ const titleFor = (event) => TITLES[event.reason] ?? TITLE;
39
+ const typedLoss = (event) => event.reason === "recent" || event.reason === "all-typed";
40
+ const toneFor = (event) => (typedLoss(event) ? "info" : "warning");
23
41
  const credentials = () => (saveToken() ? "omit" : "same-origin");
42
+ const settling = (current) =>
43
+ isSaveInProgress() || (current.lastExternal > 0 && Date.now() - current.lastExternal < EXTERNAL_WINDOW_MS);
24
44
  const headers = (extra = {}) => ({ "Document-URL": location.href, ...extra });
25
45
 
26
46
  function removeServerBar() {
@@ -34,7 +54,7 @@ function say(message, type) {
34
54
  }, () => {});
35
55
  }
36
56
 
37
- function describe(current, event) {
57
+ function sourceOf(event) {
38
58
  const summary = event.lossSummary || {};
39
59
  const source = summary.provenance === "external" ? "A change from outside this page" : "A save from this page";
40
60
  const loss = summary.removedItems > 0
@@ -43,12 +63,54 @@ function describe(current, event) {
43
63
  const row = Array.isArray(event.preview) ? event.preview[0] : null;
44
64
  const parts = [`${source} ${loss}.`];
45
65
  if (row) parts.push(`${row.key}: ${row.yours} before, ${row.now} now.`);
46
- if (canRestore(current, event)) {
47
- if (event.droppedAdditions > 0) parts.push(`Restoring removes ${plural(event.droppedAdditions, "item")} added or changed since.`);
48
- } else if (canRevert(current, event)) {
49
- parts.push("This data no longer fits the page, but the whole change can be undone.");
66
+ return parts;
67
+ }
68
+
69
+ function labelFor(path) {
70
+ const parts = String(path ?? "").split(".").filter((part) => part && !/^\[[^\]]*\]$/.test(part));
71
+ const last = (parts[parts.length - 1] ?? "").replace(/\[[^\]]*\]/g, "").trim();
72
+ return last.replace(/[_-]+/g, " ") || "value";
73
+ }
74
+
75
+ function typedLine(event) {
76
+ const rows = typed(event);
77
+ if (!rows.length) return "";
78
+ const shown = rows.slice(0, TYPED_SHOWN).map((row) => `${labelFor(row.path)}: "${row.yours}"`);
79
+ const more = rows.length - shown.length;
80
+ return `${shown.join("; ")}${more > 0 ? ` and ${more} more.` : "."}`;
81
+ }
82
+
83
+ function typedWhen(event) {
84
+ if (event.reason === "all-typed") return "";
85
+ const row = typed(event)[0];
86
+ const at = typeof row?.at === "number" ? row.at : Date.parse(row?.at);
87
+ if (!Number.isFinite(at)) return "";
88
+ const ago = Date.now() - at;
89
+ if (ago < 0 || ago >= HOUR_MS) return "";
90
+ const minutes = Math.max(1, Math.floor(ago / 60000));
91
+ return `You typed ${typed(event).length === 1 ? "it" : "them"} ${plural(minutes, "minute")} ago.`;
92
+ }
93
+
94
+ function detailFor(current, event) {
95
+ if (!reasoned(event)) {
96
+ const parts = sourceOf(event);
97
+ if (canRestore(current, event)) {
98
+ if (event.droppedAdditions > 0) parts.push(`Restoring removes ${plural(event.droppedAdditions, "item")} added or changed since.`);
99
+ } else if (canRevert(current, event)) {
100
+ parts.push("This data no longer fits the page, but the whole change can be undone.");
101
+ } else {
102
+ parts.push("It cannot be restored from here.");
103
+ }
104
+ if (canRevert(current, event)) parts.push("Undo the change also removes edits made since.");
105
+ return parts.join(" ");
106
+ }
107
+ const parts = [];
108
+ if (typedLoss(event)) {
109
+ parts.push(typedLine(event) || sourceOf(event).join(" "));
110
+ const when = typedWhen(event);
111
+ if (when) parts.push(when);
50
112
  } else {
51
- parts.push("It cannot be restored from here.");
113
+ parts.push(sourceOf(event).join(" "));
52
114
  }
53
115
  if (canRevert(current, event)) parts.push("Undo the change also removes edits made since.");
54
116
  return parts.join(" ");
@@ -56,14 +118,26 @@ function describe(current, event) {
56
118
 
57
119
  function actionsFor(current, event) {
58
120
  const actions = [];
59
- if (canRestore(current, event)) actions.push({ label: "Restore", onClick: () => choose(current, event, "restore") });
121
+ if (!reasoned(event)) {
122
+ if (canRestore(current, event)) actions.push({ label: "Restore", onClick: () => choose(current, event, "restore") });
123
+ if (canRevert(current, event)) actions.push({ label: "Undo the change", onClick: () => choose(current, event, "revert") });
124
+ actions.push({ label: "Keep as is", onClick: () => choose(current, event, "dismiss") });
125
+ return actions;
126
+ }
127
+ if (canPutBack(current, event)) actions.push({ label: "Put it back", onClick: () => choose(current, event, "put-back") });
60
128
  if (canRevert(current, event)) actions.push({ label: "Undo the change", onClick: () => choose(current, event, "revert") });
61
- actions.push({ label: "Keep as is", onClick: () => choose(current, event, "dismiss") });
129
+ actions.push({ label: "Keep the change", onClick: () => choose(current, event, "dismiss") });
62
130
  return actions;
63
131
  }
64
132
 
65
133
  async function draw(current, event) {
66
- const key = [event.id, canRestore(current, event), canRevert(current, event), event.lastWriteAt, describe(current, event)].join("|");
134
+ const title = titleFor(event);
135
+ const detail = detailFor(current, event);
136
+ const key = [
137
+ event.id, title, event.reason ?? "", JSON.stringify(typed(event)),
138
+ canRestore(current, event), canPutBack(current, event), canRevert(current, event),
139
+ event.lastWriteAt, detail,
140
+ ].join("|");
67
141
  if (current.drawn === key) return true;
68
142
  current.drawn = key;
69
143
  current.eventId = event.id;
@@ -77,15 +151,20 @@ async function draw(current, event) {
77
151
  return false;
78
152
  }
79
153
  if (session !== current || current.drawn !== key) return false;
80
- ui.dismissPersistent(TITLE, { now: true });
81
- ui.toastPersistent(TITLE, "warning", { detail: describe(current, event), actions: actionsFor(current, event) });
154
+ const previous = current.title;
155
+ current.title = title;
156
+ if (previous && previous !== title) ui.dismissPersistent(previous, { now: true });
157
+ ui.dismissPersistent(title, { now: true });
158
+ ui.toastPersistent(title, toneFor(event), { detail, actions: actionsFor(current, event) });
82
159
  return true;
83
160
  }
84
161
 
85
162
  function clearNotice(current) {
163
+ const title = current.title;
86
164
  current.drawn = null;
87
165
  current.eventId = null;
88
- ui?.dismissPersistent(TITLE, { now: true });
166
+ current.title = null;
167
+ if (title) ui?.dismissPersistent(title, { now: true });
89
168
  }
90
169
 
91
170
  async function read(current) {
@@ -113,7 +192,11 @@ async function read(current) {
113
192
  if (session !== current || turn !== current.turn) return;
114
193
  if (!body || typeof body !== "object" || !("event" in body)) return;
115
194
  if (isPending(body.event)) {
116
- if (current.resolved.has(body.event.id)) return;
195
+ if (current.resolved.has(body.event.id) || current.waiting === body.event.id) return;
196
+ if (!current.waited.has(body.event.id) && settling(current)) {
197
+ waitForSettle(current, body.event.id);
198
+ return;
199
+ }
117
200
  if (await draw(current, body.event)) removeServerBar();
118
201
  return;
119
202
  }
@@ -150,7 +233,7 @@ function settle(current, event, choice, status, body) {
150
233
  } else if (status === 422) {
151
234
  current.refused.add(`${event.id}:${choice}`);
152
235
  if (current.eventId === event.id && !current.resolved.has(event.id)) draw(current, event);
153
- say(choice === "restore" ? "That data no longer fits this page, so it could not be restored." : "That earlier version is no longer available.", "warning");
236
+ say(choice === "restore" || choice === "put-back" ? "That data no longer fits this page, so it could not be restored." : "That earlier version is no longer available.", "warning");
154
237
  } else if (status === 401 || status === 403) {
155
238
  say(RELOAD_FIRST, "error");
156
239
  } else {
@@ -202,17 +285,44 @@ function onNotification(current, e) {
202
285
  if (typeof data?.id !== "string") return;
203
286
  current.turn++;
204
287
  current.resolved.add(data.id);
288
+ if (current.waiting === data.id) stopWaiting(current);
205
289
  if (current.eventId === data.id) clearNotice(current);
206
290
  return;
207
291
  }
208
292
  if (action === "raised" && isPending(data) && !current.resolved.has(data.id)) {
209
293
  current.turn++;
294
+ if (current.waiting === data.id) return;
295
+ if (!current.waited.has(data.id) && settling(current)) {
296
+ waitForSettle(current, data.id);
297
+ return;
298
+ }
210
299
  draw(current, data).then((drawn) => { if (drawn) removeServerBar(); }, () => {});
211
300
  }
212
301
  }
213
302
 
214
303
  const readAt = (current) => Math.max(current.lastSave + AFTER_SAVE_MS, current.lastRead + MIN_READ_GAP_MS);
215
304
 
305
+ function stopWaiting(current) {
306
+ if (!current.waiting) return false;
307
+ current.waiting = null;
308
+ clearTimeout(current.waitTimer);
309
+ current.waitTimer = null;
310
+ return true;
311
+ }
312
+
313
+ function endWait(current, { now = false } = {}) {
314
+ if (!stopWaiting(current) || session !== current) return;
315
+ if (now) read(current);
316
+ else armRead(current);
317
+ }
318
+
319
+ function waitForSettle(current, id) {
320
+ current.waited.add(id);
321
+ current.waiting = id;
322
+ clearTimeout(current.waitTimer);
323
+ current.waitTimer = setTimeout(() => endWait(current, { now: true }), SETTLE_CAP_MS);
324
+ }
325
+
216
326
  function armRead(current) {
217
327
  if (current.timer) return;
218
328
  current.timer = setTimeout(() => {
@@ -235,22 +345,30 @@ export function start({ href, reload = () => window.location.reload() }) {
235
345
  const listeners = new AbortController();
236
346
  const current = {
237
347
  url: url.href, reload, listeners, turn: 0, lastRead: 0, lastSave: 0, timer: null,
238
- busy: false, drawn: null, eventId: null, resolved: new Set(), refused: new Set(),
348
+ lastExternal: 0, waiting: null, waitTimer: null, waited: new Set(),
349
+ busy: false, drawn: null, eventId: null, title: null, resolved: new Set(), refused: new Set(),
239
350
  };
240
351
  session = current;
241
352
  const { signal } = listeners;
242
353
  document.addEventListener("clay:notification", (e) => onNotification(current, e), { signal });
354
+ document.addEventListener("clay:sync-applied", (e) => {
355
+ if (e.detail?.source === "disk") current.lastExternal = Date.now();
356
+ }, { signal });
243
357
  document.addEventListener("clay:save-saved", () => {
244
358
  current.lastSave = Date.now();
245
- armRead(current);
359
+ if (current.waiting) endWait(current);
360
+ else armRead(current);
246
361
  }, { signal });
362
+ for (const name of SAVE_UNSETTLED_EVENTS) {
363
+ document.addEventListener(name, () => endWait(current, { now: true }), { signal });
364
+ }
247
365
  document.addEventListener("visibilitychange", () => {
248
366
  if (document.visibilityState === "visible" && Date.now() - current.lastRead >= VISIBLE_GAP_MS) read(current);
249
367
  }, { signal });
250
368
  const done = storage.session.get(DONE_KEY);
251
369
  if (done) {
252
370
  storage.session.remove(DONE_KEY);
253
- say(done === "restore" ? "Your saved data is back." : "The earlier version is back.", "success");
371
+ say(done === "restore" || done === "put-back" ? "Your saved data is back." : "The earlier version is back.", "success");
254
372
  }
255
373
  read(current);
256
374
  }
@@ -261,5 +379,6 @@ export function stop() {
261
379
  session = null;
262
380
  current.listeners.abort();
263
381
  clearTimeout(current.timer);
382
+ clearTimeout(current.waitTimer);
264
383
  clearNotice(current);
265
384
  }