@panphora/clayjs 1.25.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) 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 +4432 -3831
  11. package/dist/m/attrs/{save-freeze-MH3AOD56.js → save-freeze-PUYX32AK.js} +1 -1
  12. package/dist/m/{c-XHZIKRCV.js → c-2A2ATCIH.js} +1 -1
  13. package/dist/m/{c-BOMQIQ3V.js → c-2EFMJYGJ.js} +1 -1
  14. package/dist/m/{c-JD2VFAYG.js → c-2GB34XL4.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-ZEA3DCG3.js → c-3AAQJAKJ.js} +1 -1
  19. package/dist/m/c-3F3AADKG.js +1 -0
  20. package/dist/m/{c-GDNZEOOZ.js → c-3WX5FABT.js} +1 -1
  21. package/dist/m/{c-QWB53KRW.js → c-3XNTQQEJ.js} +1 -1
  22. package/dist/m/{c-AI3H6ETU.js → c-3Y234AUB.js} +2 -2
  23. package/dist/m/{c-GEAG5XAK.js → c-42RLKGEY.js} +1 -1
  24. package/dist/m/{c-XQTERKQA.js → c-44WITKWQ.js} +1 -1
  25. package/dist/m/c-47M4QHCK.js +1 -0
  26. package/dist/m/{c-UOUUI265.js → c-4M2T676V.js} +1 -1
  27. package/dist/m/{c-3E2IK5HU.js → c-4NAYDOWH.js} +1 -1
  28. package/dist/m/{c-TLQINES7.js → c-4OZZ7ENT.js} +2 -2
  29. package/dist/m/{c-QDVG4NAZ.js → c-5IIEOPIV.js} +1 -1
  30. package/dist/m/{c-4Q6QIODW.js → c-755ZOHOM.js} +1 -1
  31. package/dist/m/c-7Q4XYDF4.js +1 -0
  32. package/dist/m/c-7RHOMAMR.js +1 -0
  33. package/dist/m/{c-BCWYEG6P.js → c-7Z2625LX.js} +1 -1
  34. package/dist/m/{c-C6YZNU6E.js → c-A3OKSYRI.js} +1 -1
  35. package/dist/m/{c-RGAALCDU.js → c-A5IYL7JI.js} +1 -1
  36. package/dist/m/c-A5LASUEZ.js +1 -0
  37. package/dist/m/c-AQVQKFKB.js +1 -0
  38. package/dist/m/{c-IESDKFO6.js → c-ASGHHLFX.js} +1 -1
  39. package/dist/m/c-ASMLERFA.js +7 -0
  40. package/dist/m/{c-ORLPQFNU.js → c-AZM34SPO.js} +1 -1
  41. package/dist/m/{c-TCFVG3I4.js → c-BHLYQGTA.js} +1 -1
  42. package/dist/m/{c-ZORVIXUR.js → c-C27EVUQ6.js} +1 -1
  43. package/dist/m/{c-SUAC5RSG.js → c-C5UGNOC2.js} +1 -1
  44. package/dist/m/c-C6DXZIXD.js +1 -0
  45. package/dist/m/{c-WSTUCBHV.js → c-CPRGR2II.js} +1 -1
  46. package/dist/m/{c-NFQFV7OP.js → c-D2OPWOWO.js} +1 -1
  47. package/dist/m/{c-PRZNJUYZ.js → c-D4N4UJCQ.js} +2 -2
  48. package/dist/m/{c-D62HRSZK.js → c-DAUQ6JZ3.js} +1 -1
  49. package/dist/m/{c-5PZYDWTW.js → c-DDCYVLGG.js} +1 -1
  50. package/dist/m/{c-FBOO2UY3.js → c-DVMDYRAE.js} +1 -1
  51. package/dist/m/{c-WGQVSKKC.js → c-DYTBSJBI.js} +1 -1
  52. package/dist/m/{c-O2QZDVZ2.js → c-EEWPNUM2.js} +1 -1
  53. package/dist/m/{c-MFB74XVN.js → c-EHAR4RJJ.js} +2 -2
  54. package/dist/m/c-EIDZHQHZ.js +1 -0
  55. package/dist/m/c-ELXMSEN5.js +1 -0
  56. package/dist/m/c-FC5RUFPR.js +1 -0
  57. package/dist/m/{c-V4B3RUWB.js → c-GFASNR55.js} +1 -1
  58. package/dist/m/{c-4AE2A2LK.js → c-GNFDX6SU.js} +1 -1
  59. package/dist/m/{c-UD4VDBGJ.js → c-H3M6UDNV.js} +1 -1
  60. package/dist/m/{c-MUM53J5J.js → c-H4M2T4A6.js} +1 -1
  61. package/dist/m/{c-U7F66I64.js → c-HYNM5U6B.js} +1 -1
  62. package/dist/m/c-J2YYLEVM.js +1 -0
  63. package/dist/m/c-J2ZW4HMR.js +1 -0
  64. package/dist/m/{c-WCG6AKA7.js → c-JC4RZW4E.js} +1 -1
  65. package/dist/m/c-K2ZHQMPF.js +1 -0
  66. package/dist/m/{c-Z2OZFX3N.js → c-L6KVXPBO.js} +1 -1
  67. package/dist/m/{c-JWW6BHWD.js → c-LUBZU4AZ.js} +4 -4
  68. package/dist/m/{c-SEVOS4UE.js → c-LYNLY3MR.js} +1 -1
  69. package/dist/m/{c-NADLYVAH.js → c-MHTO3QAD.js} +1 -1
  70. package/dist/m/{c-SFYTKRU7.js → c-MKAUC6DB.js} +1 -1
  71. package/dist/m/c-MLMJUNRV.js +1 -0
  72. package/dist/m/{c-WDFQ7PBU.js → c-MMEVDN6D.js} +1 -1
  73. package/dist/m/{c-KYTVZT4D.js → c-NW4ZFKCY.js} +1 -1
  74. package/dist/m/{c-YFO6ZJLQ.js → c-O3DPFZCE.js} +1 -1
  75. package/dist/m/c-O4YIDULY.js +1 -0
  76. package/dist/m/c-OZOACVUD.js +1 -0
  77. package/dist/m/{c-MV7TEUTD.js → c-OZVK3TFI.js} +1 -1
  78. package/dist/m/c-PP3CUF3U.js +1 -0
  79. package/dist/m/{c-GY4BREDT.js → c-QL4NYM2L.js} +1 -1
  80. package/dist/m/c-RES2S5Q3.js +1 -0
  81. package/dist/m/c-RFGELYEU.js +2 -0
  82. package/dist/m/c-RGFPLQGV.js +1 -0
  83. package/dist/m/{c-FKTALXNV.js → c-STAPQ2X6.js} +1 -1
  84. package/dist/m/c-SYVF6ZMF.js +1 -0
  85. package/dist/m/{c-X3V4RWMA.js → c-T5IWJOMW.js} +1 -1
  86. package/dist/m/{c-DT7E76Z2.js → c-T7LOTEWO.js} +1 -1
  87. package/dist/m/{c-SFZRY6RT.js → c-TWB5ZQU4.js} +1 -1
  88. package/dist/m/c-U35T5NAY.js +1 -0
  89. package/dist/m/c-UW6MMZ4V.js +1 -0
  90. package/dist/m/c-VGG3UR7I.js +1 -0
  91. package/dist/m/c-VU4WEWPH.js +4 -0
  92. package/dist/m/{c-5Q5XBN56.js → c-W7LWCJHD.js} +1 -1
  93. package/dist/m/c-WOJ2RTV4.js +1 -0
  94. package/dist/m/{c-ZJG6C2WL.js → c-WOWGGNCJ.js} +1 -1
  95. package/dist/m/{c-SXZYVXD6.js → c-XCOQNISP.js} +1 -1
  96. package/dist/m/{c-IL5IVTOG.js → c-XS7G6U3Z.js} +1 -1
  97. package/dist/m/{c-HGG7M2S3.js → c-Y3J5LDKB.js} +1 -1
  98. package/dist/m/{c-JB5YU57S.js → c-Y3MPVURA.js} +1 -1
  99. package/dist/m/c-Y6HH7LTM.js +1 -0
  100. package/dist/m/{c-52OC3OH3.js → c-YRDKVEGR.js} +1 -1
  101. package/dist/m/{c-WVCT3ITX.js → c-YZGWSCMT.js} +1 -1
  102. package/dist/m/{c-YX2MC7YL.js → c-ZJ7726FO.js} +1 -1
  103. package/dist/m/core/{admin-attrs-ZI6K6UGM.js → admin-attrs-43JBA6JB.js} +1 -1
  104. package/dist/m/core/{autosave-BSM2ABJL.js → autosave-HD7Y7UNV.js} +1 -1
  105. package/dist/m/core/{conflict-notice-7EQLDYPA.js → conflict-notice-Y2NTMDGJ.js} +1 -1
  106. package/dist/m/core/{persist-2SZO3PCE.js → persist-MX3VYVBN.js} +1 -1
  107. package/dist/m/core/{save-K6Q47J2X.js → save-JZ7R4ET2.js} +1 -1
  108. package/dist/m/core/{save-core-U3Z44XBO.js → save-core-I4N6S4ED.js} +1 -1
  109. package/dist/m/core/{site-edits-CFMF6GHD.js → site-edits-TIJDPG6H.js} +1 -1
  110. package/dist/m/core/{snapshot-PWCHYFL6.js → snapshot-N5MKDGNI.js} +1 -1
  111. package/dist/m/core/{unsaved-warning-2WRZLJH5.js → unsaved-warning-BP6ZY3PG.js} +1 -1
  112. package/dist/m/internals/{index-FSLC4M7J.js → index-3LAVREAU.js} +1 -1
  113. package/dist/m/{loader-RC54UV7H.js → loader-7ORQVRCK.js} +1 -1
  114. package/dist/m/manifest-A27A4278.js +1 -0
  115. package/dist/m/manifest-edit-E8A61EB4.js +1 -0
  116. package/dist/m/manifest.json +1099 -1039
  117. package/dist/m/page/{editor-PKSPJ2XJ.js → editor-HS2CAV3W.js} +5 -5
  118. package/dist/m/plugins/{ai-edit-3LG6MGS3.js → ai-edit-WDV2GLDU.js} +2 -2
  119. package/dist/m/plugins/{cms-T2HLI7JG.js → cms-4KET6RZT.js} +9 -9
  120. package/dist/m/plugins/{derive-7DR5TUDH.js → derive-R4USHZ7P.js} +1 -1
  121. package/dist/m/plugins/{indicator-CTKX4EY7.js → indicator-OLPZ4WFI.js} +1 -1
  122. package/dist/m/plugins/{live-IFLL6YN5.js → live-CO4VJS46.js} +1 -1
  123. package/dist/m/plugins/{menu-YJZ3JWBC.js → menu-ESEFQARM.js} +1 -1
  124. package/dist/m/plugins/{page-GTDPYGE4.js → page-L3VKIQU2.js} +2 -2
  125. package/dist/m/plugins/{page-view-NMCCU6MF.js → page-view-6KNTXABH.js} +1 -1
  126. package/dist/m/plugins/{people-6WIWCZEG.js → people-EBWE337I.js} +1 -1
  127. package/dist/m/plugins/quickcrop-VNRXGRPC.js +26 -0
  128. package/dist/m/plugins/{richclay-L3BZMX4I.js → richclay-V6WKJTUF.js} +17 -17
  129. package/dist/m/plugins/sortable-BAG3MSWS.js +1 -0
  130. package/dist/m/plugins/{source-7LSGOV46.js → source-OYHDXKTB.js} +1 -1
  131. package/dist/m/plugins/undo-WUK2NLBA.js +1 -0
  132. package/dist/m/plugins/{upgrade-MMPZUCDM.js → upgrade-SZQJYEQQ.js} +1 -1
  133. package/dist/m/plugins/{visitor-drafts-V5YB7XDH.js → visitor-drafts-ZE6PCAFP.js} +2 -2
  134. package/dist/m/plugins/{visitor-menu-QDV2MYJN.js → visitor-menu-5XY57JQA.js} +1 -1
  135. package/dist/m/plugins/{wire-3YGUO3GE.js → wire-S5ATC4QI.js} +1 -1
  136. package/dist/m/reader-core-AHOAP5DH.js +77 -0
  137. package/dist/m/sync/{live-sync-XMXXZS3G.js → live-sync-DX4K5GDB.js} +1 -1
  138. package/dist/m/ui/index-G7ZJXNMG.js +1 -0
  139. package/dist/m/vendor/{sap-core.vendor-JANUQULT.js → sap-core.vendor-5TWOJYVM.js} +4 -4
  140. package/dist/offline-manifest.json +707 -677
  141. package/dist/server/core.cjs +4245 -2614
  142. package/dist/server/markdown.cjs +1 -0
  143. package/dist/server/markdown.mjs +7 -0
  144. package/docs/guide.md +26 -37
  145. package/entries/clay-data.js +1 -1
  146. package/entries/sap.js +5 -5
  147. package/package.json +6 -2
  148. package/packed-contract.json +3 -0
  149. package/src/attrs/save-freeze.js +1 -1
  150. package/src/core/admin-contenteditable.js +1 -1
  151. package/src/core/admin-inputs.js +1 -1
  152. package/src/core/admin-onclick.js +1 -1
  153. package/src/core/admin-resources.js +5 -2
  154. package/src/core/conflict-notice-ui.js +59 -34
  155. package/src/core/save.js +9 -9
  156. package/src/core/snapshot.js +56 -43
  157. package/src/core/stale-host-notice.js +20 -7
  158. package/src/page/editor.js +10 -4
  159. package/src/page/import-markdown.js +22 -8
  160. package/src/page/save-projection.js +1 -1
  161. package/src/plugins/quickcrop.js +128 -69
  162. package/src/plugins/sortable.js +25 -9
  163. package/src/server/markdown/index.js +23 -0
  164. package/src/sync/conflicts.js +3 -1
  165. package/src/sync/data-guard-notice.js +137 -18
  166. package/src/ui/bevel-controls.js +4 -1
  167. package/src/ui/dialogs.js +11 -7
  168. package/src/ui/modal.js +9 -7
  169. package/src/ui/toast.js +67 -32
  170. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  171. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  172. package/src/vendor/hyper-undo.vendor.js +1 -1
  173. package/src/vendor/hypercms.vendor.js +7 -7
  174. package/src/vendor/richclay.vendor.js +16 -16
  175. package/src/vendor/sap-core.vendor.js +4 -4
  176. package/tool-generation/context.md +69 -0
  177. package/tool-generation/system.md +1 -1
  178. package/dist/m/c-277QP5ET.js +0 -1
  179. package/dist/m/c-5SZ6KFR5.js +0 -1
  180. package/dist/m/c-CU33V6RW.js +0 -4
  181. package/dist/m/c-CVSWLX3X.js +0 -1
  182. package/dist/m/c-DXPOFLEJ.js +0 -1
  183. package/dist/m/c-EODTTNVA.js +0 -1
  184. package/dist/m/c-ESK4S67I.js +0 -1
  185. package/dist/m/c-FDWR3FL7.js +0 -1
  186. package/dist/m/c-FJDTDGDA.js +0 -1
  187. package/dist/m/c-FYDKYW6E.js +0 -1
  188. package/dist/m/c-HKSLOVU4.js +0 -1
  189. package/dist/m/c-IJHF4IO6.js +0 -1
  190. package/dist/m/c-IUL7PU4P.js +0 -1
  191. package/dist/m/c-K4M76VPM.js +0 -7
  192. package/dist/m/c-M7ITYKMF.js +0 -1
  193. package/dist/m/c-MR75YPRT.js +0 -1
  194. package/dist/m/c-NHXOBD6A.js +0 -1
  195. package/dist/m/c-NVU3DORS.js +0 -1
  196. package/dist/m/c-PNZIJFG7.js +0 -1
  197. package/dist/m/c-RHPHMTF2.js +0 -2
  198. package/dist/m/c-RWJ3OSCP.js +0 -1
  199. package/dist/m/c-SGG6MUXW.js +0 -1
  200. package/dist/m/c-SXNSSHPP.js +0 -1
  201. package/dist/m/c-TFLRT5JX.js +0 -1
  202. package/dist/m/c-U65PREMC.js +0 -1
  203. package/dist/m/c-VMXCX52Q.js +0 -1
  204. package/dist/m/c-YYGUHSZR.js +0 -1
  205. package/dist/m/manifest-3B84C829.js +0 -1
  206. package/dist/m/manifest-edit-1CB79AD4.js +0 -1
  207. package/dist/m/plugins/quickcrop-X5MRIBAP.js +0 -26
  208. package/dist/m/plugins/sortable-365SRRL3.js +0 -1
  209. package/dist/m/plugins/undo-UES4KS7B.js +0 -1
  210. package/dist/m/reader-core-VR7EL4WV.js +0 -77
  211. package/dist/m/ui/index-MGTWOW2C.js +0 -1
  212. /package/dist/m/{c-QTTJYJMA.js → c-FILLTJA7.js} +0 -0
  213. /package/dist/m/plugins/{quickcrop-X5MRIBAP.js.LEGAL.txt → quickcrop-VNRXGRPC.js.LEGAL.txt} +0 -0
  214. /package/dist/m/plugins/{richclay-L3BZMX4I.js.LEGAL.txt → richclay-V6WKJTUF.js.LEGAL.txt} +0 -0
@@ -17,8 +17,8 @@ import { safeHref, safeSrc } from "./address.js";
17
17
  const EMBEDS = "iframe, frame, frameset, object, embed, applet, meta, base, link, form, style, animate, set, animateMotion, animateTransform, portal";
18
18
  const LOADERS = /^(srcdoc|data|formaction|codebase|archive|ping|background|poster|lowsrc|dynsrc)$/i;
19
19
  const SCRIPTED = /^\s*(javascript|vbscript|data):/i;
20
- function cleanImported(html) {
21
- const t = document.createElement("template");
20
+ function cleanImported(html, doc) {
21
+ const t = doc.createElement("template");
22
22
  t.innerHTML = html;
23
23
  for (const el of t.content.querySelectorAll(EMBEDS)) el.remove();
24
24
  for (const el of t.content.querySelectorAll("*")) {
@@ -57,7 +57,19 @@ function added(marks, name, attrs) {
57
57
  // One text block's inline content as the record keeps it: c, and m as runs (one
58
58
  // key per UTF-16 unit, packed), exactly as flatten and runsOf write it; `parts`
59
59
  // splits around an image, which the schema only carries as a block of its own.
60
- function inlineOf(tokens, { images = true, refused = [], unsupported = [] } = {}) {
60
+ // A link or image written as a relative path (`other.md`, `setup.sh`, `shot.png`) names a
61
+ // file beside the Markdown it came from. It never becomes a web address: `setup.sh` read
62
+ // as a host name is someone else's site, and an image there would make every reader's
63
+ // browser ask that site for it. The host may map it (`resolveLink`, `resolveImage`).
64
+ const RELATIVE = /^(?![a-z][a-z0-9+.-]*:|[/#?])\S/i;
65
+
66
+ function linkHref(raw, resolveLink) {
67
+ const resolved = typeof resolveLink === "function" ? resolveLink(raw) : null;
68
+ if (typeof resolved === "string" && resolved) return safeHref(resolved);
69
+ return RELATIVE.test(raw.trim()) ? null : safeHref(raw);
70
+ }
71
+
72
+ function inlineOf(tokens, { images = true, refused = [], unsupported = [], resolveLink = null } = {}) {
61
73
  const parts = [];
62
74
  let c = "";
63
75
  let m = [];
@@ -91,7 +103,7 @@ function inlineOf(tokens, { images = true, refused = [], unsupported = [] } = {}
91
103
  case "codespan": text(token.text, added(marks, "code")); break;
92
104
  case "br": atom(BREAK); break;
93
105
  case "link": {
94
- const href = safeHref(token.href);
106
+ const href = linkHref(String(token.href ?? ""), resolveLink);
95
107
  if (href) walk(token.tokens, added(marks, "link", { href }));
96
108
  else {
97
109
  refused.push({ type: "link", href: String(token.href ?? "") });
@@ -121,7 +133,7 @@ function inlineOf(tokens, { images = true, refused = [], unsupported = [] } = {}
121
133
  return { parts };
122
134
  }
123
135
 
124
- export function importMarkdownTokens(tokens) {
136
+ export function importMarkdownTokens(tokens, { document: doc = globalThis.document, resolveImage = null, resolveLink = null } = {}) {
125
137
  if (!Array.isArray(tokens)) throw new MarkdownImportError("malformed-input", "Markdown import needs the lexer's token list.");
126
138
  let size = 0;
127
139
  for (const token of tokens) {
@@ -155,7 +167,9 @@ export function importMarkdownTokens(tokens) {
155
167
  return value;
156
168
  };
157
169
  const imageOf = (image) => {
158
- const src = safeSrc(image.src);
170
+ const resolved = typeof resolveImage === "function" ? resolveImage(image.src) : null;
171
+ const mapped = typeof resolved === "string" && resolved ? resolved : null;
172
+ const src = mapped ? safeSrc(mapped) : RELATIVE.test(String(image.src).trim()) ? null : safeSrc(image.src);
159
173
  if (src) return { t: "image", src, ...(image.alt ? { alt: image.alt } : {}), ...(image.title ? { caption: image.title } : {}) };
160
174
  refused.push({ type: "image", href: image.src });
161
175
  return image.alt ? block("paragraph", {}, { c: image.alt, m: [] }) : null;
@@ -168,7 +182,7 @@ export function importMarkdownTokens(tokens) {
168
182
  }
169
183
  };
170
184
  const paragraph = (inline, ctx) => textBlocks(ctx.quote ? "quote" : "paragraph", {}, inline);
171
- const inline = (tokens, options = {}) => inlineOf(tokens, { refused, unsupported, ...options });
185
+ const inline = (tokens, options = {}) => inlineOf(tokens, { refused, unsupported, resolveLink, ...options });
172
186
  const literal = (token) => {
173
187
  const raw = String(token.raw ?? token.text ?? "");
174
188
  unsupported.push({ type: token.type, text: raw });
@@ -193,7 +207,7 @@ export function importMarkdownTokens(tokens) {
193
207
  case "html": {
194
208
  const raw = String(token.text ?? token.raw ?? "");
195
209
  unsupported.push({ type: "HTML block cleaned", text: raw });
196
- push({ t: "preserved", html: cleanImported(fragmentCleaner(document).fallback(raw)) });
210
+ push({ t: "preserved", html: cleanImported(fragmentCleaner(doc).fallback(raw), doc) });
197
211
  break;
198
212
  }
199
213
  case "blockquote": {
@@ -120,4 +120,4 @@ export function acceptedPageState(html) {
120
120
  }
121
121
  }
122
122
 
123
- addDocumentTransform(projectInto);
123
+ addDocumentTransform(projectInto, { required: true });
@@ -13,6 +13,9 @@ import { bevelDialog, dismissOf, keepFocusIn } from "../ui/bevel-dialog.js";
13
13
  import { bevelButton } from "../ui/bevel-controls.js";
14
14
  import { installSkin } from "../ui/vendor-skin.js";
15
15
  import { CSS as SKIN, ROOTS as SKIN_ROOTS } from "../ui/skins/quickcrop.js";
16
+ import { resolveTheme } from "../ui/theme.js";
17
+ import { themed, partRules, markParts } from "../ui/theme-parts.js";
18
+ import { style } from "../lib/hostile-css.js";
16
19
 
17
20
  const WIDTH = 844;
18
21
 
@@ -24,80 +27,136 @@ function isThemodal(m) {
24
27
  typeof m.onYes === "function" && "html" in m;
25
28
  }
26
29
 
27
- export const bevelCropAdapter = {
28
- // The stage has to fit inside the panel: the overlay's 16px side inset, the panel's
29
- // 1px border and the body's 22px padding a side; below it the header, the footer and
30
- // the overlay's top inset. Widths come from clientWidth, which leaves out a classic
31
- // scrollbar as the overlay does.
32
- fit() {
33
- const width = document.documentElement.clientWidth;
34
- return {
35
- width: Math.max(120, Math.min(WIDTH, width - 32) - 2 - 44),
36
- height: Math.max(120, window.innerHeight - Math.min(96, window.innerHeight * 0.1) - 200),
37
- };
38
- },
39
- open({ content, confirmLabel, onConfirm, onCancel }) {
40
- const { root, overlay, panel, heading, body, footer, close } = bevelDialog({
41
- zIndex: "2147483001", width: `${WIDTH}px`, closable: true, titled: true,
42
- });
43
- heading.textContent = "Crop image";
44
- panel.setAttribute("aria-label", "Crop image");
45
- body.append(content);
46
- const confirm = bevelButton(confirmLabel, { variant: "primary", onClick: () => onConfirm() });
47
- footer.append(confirm);
48
-
49
- close.addEventListener("click", () => onCancel());
50
- panel.addEventListener("submit", (event) => event.preventDefault());
51
- let pressedOnBackdrop = false;
52
- overlay.addEventListener("mousedown", (event) => { pressedOnBackdrop = event.target === overlay; });
53
- overlay.addEventListener("click", (event) => {
54
- if (pressedOnBackdrop && event.target === overlay) onCancel();
55
- pressedOnBackdrop = false;
56
- });
57
- // Capture, and stopped there, so a dialog underneath (a CMS confirm through
58
- // clay.modal) does not take the same key as its own: Escape cancels this crop,
59
- // Tab stays inside it.
60
- const onKey = (event) => {
61
- if (event.key === "Tab") {
30
+ const TITLE = "Crop image";
31
+
32
+ // The frame for one crop, in the theme captured for that crop. Measuring and opening
33
+ // build the same frame, so the stage is sized for the padding, borders, heading and
34
+ // button the person will actually see.
35
+ function cropFrame(theme, confirmLabel, onConfirm = null) {
36
+ const frame = bevelDialog({ zIndex: "2147483001", width: `${WIDTH}px`, closable: true, titled: true, theme, surface: "crop" });
37
+ frame.heading.textContent = TITLE;
38
+ frame.panel.setAttribute("aria-label", TITLE);
39
+ const confirm = bevelButton(confirmLabel, {
40
+ variant: "primary", onClick: onConfirm, theme,
41
+ parts: ["dialog.button", "crop.button"], labelParts: ["dialog.buttonLabel", "crop.buttonLabel"],
42
+ });
43
+ frame.footer.append(confirm);
44
+ return { ...frame, confirm };
45
+ }
46
+
47
+ // The stage is quickcrop's; its parts take only a page's explicit crop.* declarations,
48
+ // so dimming and crop lines keep their contrast unless the page asks otherwise.
49
+ function themeStage(stage, theme) {
50
+ if (!themed(theme)) return;
51
+ const paint = (el, names) => {
52
+ if (!el) return;
53
+ markParts(el, theme, names);
54
+ style(el, partRules(theme, names, {}));
55
+ };
56
+ paint(stage, ["crop.stage"]);
57
+ paint(stage.querySelector(":scope > img"), ["crop.image"]);
58
+ paint(stage.querySelector(".qc-dim"), ["crop.dim"]);
59
+ paint(stage.querySelector(".qc-box"), ["crop.selection"]);
60
+ for (const corner of ["nw", "ne", "sw", "se"]) paint(stage.querySelector(`.qc-handle.qc-${corner}`), ["crop.handle", `crop.handle.${corner}`]);
61
+ }
62
+
63
+ // A themed frame is measured: a hidden copy is mounted for one layout read and removed,
64
+ // without touching focus or the scroll lock. The room left for the stage is the body's
65
+ // inner width, and the overlay's inner height less the empty panel. The exact Bevel
66
+ // frame keeps its fixed allowances.
67
+ function measure(theme, confirmLabel) {
68
+ const { root, overlay, panel, body } = cropFrame(theme, confirmLabel);
69
+ root.setAttribute("aria-hidden", "true");
70
+ style(root, ["visibility:hidden", "pointer-events:none"]);
71
+ document.body.append(root);
72
+ try {
73
+ const px = (value) => parseFloat(value) || 0;
74
+ const o = getComputedStyle(overlay), b = getComputedStyle(body);
75
+ const width = body.clientWidth - px(b.paddingLeft) - px(b.paddingRight);
76
+ const height = overlay.clientHeight - px(o.paddingTop) - px(o.paddingBottom) - panel.offsetHeight;
77
+ return { width: Math.max(1, Math.floor(width)), height: Math.max(1, Math.floor(height)) };
78
+ } finally {
79
+ root.remove();
80
+ }
81
+ }
82
+
83
+ export function createCropAdapter({ theme = null, confirmLabel = "Crop" } = {}) {
84
+ return {
85
+ // Unthemed, the stage fits inside the panel by fixed allowances: the overlay's 16px
86
+ // side inset, the panel's 1px border and the body's 22px padding a side; below it the
87
+ // header, the footer and the overlay's top inset. Widths come from clientWidth, which
88
+ // leaves out a classic scrollbar as the overlay does.
89
+ fit() {
90
+ if (themed(theme)) return measure(theme, confirmLabel);
91
+ const width = document.documentElement.clientWidth;
92
+ return {
93
+ width: Math.max(120, Math.min(WIDTH, width - 32) - 2 - 44),
94
+ height: Math.max(120, window.innerHeight - Math.min(96, window.innerHeight * 0.1) - 200),
95
+ };
96
+ },
97
+ open({ content, confirmLabel: label, onConfirm, onCancel }) {
98
+ const { root, overlay, panel, body, close, confirm } = cropFrame(theme, label, () => onConfirm());
99
+ themeStage(content, theme);
100
+ body.append(content);
101
+
102
+ close.addEventListener("click", () => onCancel());
103
+ panel.addEventListener("submit", (event) => event.preventDefault());
104
+ let pressedOnBackdrop = false;
105
+ overlay.addEventListener("mousedown", (event) => { pressedOnBackdrop = event.target === overlay; });
106
+ overlay.addEventListener("click", (event) => {
107
+ if (pressedOnBackdrop && event.target === overlay) onCancel();
108
+ pressedOnBackdrop = false;
109
+ });
110
+ // Capture, and stopped there, so a dialog underneath (a CMS confirm through
111
+ // clay.modal) does not take the same key as its own: Escape cancels this crop,
112
+ // Tab stays inside it.
113
+ const onKey = (event) => {
114
+ if (event.key === "Tab") {
115
+ event.stopPropagation();
116
+ keepFocusIn(panel, event);
117
+ return;
118
+ }
119
+ if (event.key !== "Escape") return;
62
120
  event.stopPropagation();
63
- keepFocusIn(panel, event);
64
- return;
65
- }
66
- if (event.key !== "Escape") return;
67
- event.stopPropagation();
68
- onCancel();
69
- };
70
- document.addEventListener("keydown", onKey, true);
71
-
72
- // The page does not scroll behind the crop, as it did not under themodal. The
73
- // previous value comes back, so a modal underneath keeps its own lock.
74
- const overflow = document.body.style.overflow;
75
- document.body.style.overflow = "hidden";
76
-
77
- let done = false;
78
- const finish = () => {
79
- if (done) return;
80
- done = true;
81
- document.removeEventListener("keydown", onKey, true);
82
- document.body.style.overflow = overflow;
83
- root.remove();
84
- };
85
- // A modal opened on top supersedes this crop the way it supersedes itself: the
86
- // crop is cancelled, so its promise settles and the next crop can open.
87
- dismissOf.set(root, () => { finish(); onCancel(); });
88
-
89
- root.setAttribute("aria-hidden", "false");
90
- document.body.append(root);
91
- confirm.focus();
92
-
93
- return { close: finish };
94
- },
95
- };
121
+ onCancel();
122
+ };
123
+ document.addEventListener("keydown", onKey, true);
124
+
125
+ // The page does not scroll behind the crop, as it did not under themodal. The
126
+ // previous value comes back, so a modal underneath keeps its own lock.
127
+ const overflow = document.body.style.overflow;
128
+ document.body.style.overflow = "hidden";
129
+
130
+ let done = false;
131
+ const finish = () => {
132
+ if (done) return;
133
+ done = true;
134
+ document.removeEventListener("keydown", onKey, true);
135
+ document.body.style.overflow = overflow;
136
+ root.remove();
137
+ };
138
+ // A modal opened on top supersedes this crop the way it supersedes itself: the
139
+ // crop is cancelled, so its promise settles and the next crop can open.
140
+ dismissOf.set(root, () => { finish(); onCancel(); });
141
+
142
+ root.setAttribute("aria-hidden", "false");
143
+ document.body.append(root);
144
+ confirm.focus();
145
+
146
+ return { close: finish };
147
+ },
148
+ };
149
+ }
150
+
151
+ // The exact Bevel frame, for callers that want ClayJS's crop without the page's theme.
152
+ export const bevelCropAdapter = createCropAdapter();
96
153
 
97
154
  export function quickcrop(file, options = {}) {
98
155
  const modal = options.modal ?? "auto";
99
156
  const framed = modal === "auto" || isThemodal(modal);
100
- return vendorQuickcrop(file, framed ? { ...options, modal: bevelCropAdapter } : options);
157
+ if (!framed) return vendorQuickcrop(file, options);
158
+ const adapter = createCropAdapter({ theme: resolveTheme(), confirmLabel: options.labels?.confirm ?? "Crop" });
159
+ return vendorQuickcrop(file, { ...options, modal: adapter });
101
160
  }
102
161
 
103
162
  export default quickcrop;
@@ -217,15 +217,31 @@ function makeSortable(sortableElem, Sortable, record = loose) {
217
217
  // Add onsorting callback if attribute exists (fires during drag)
218
218
  const onsortingCode = sortableElem.getAttribute('onsorting');
219
219
  if (onsortingCode) {
220
- options.onMove = function(evt) {
221
- if (!record.admitted) return;
222
- try {
223
- const asyncFn = new Function(`return (async function(evt) { ${onsortingCode} })`)();
224
- asyncFn.call(sortableElem, evt);
225
- } catch (error) {
226
- console.error('Error in onsorting execution:', error);
227
- }
228
- };
220
+ // Sortable reads onMove's return value (false cancels the move, 1 or -1 picks a
221
+ // side), so the handler runs synchronously and its value goes back. A handler that
222
+ // awaits cannot answer in time; it still runs, as an async function, and Sortable
223
+ // gets no answer from it.
224
+ // Code that awaits compiles as async: a plain function would read `await (x)` as a
225
+ // call to a function named await and throw on every move.
226
+ let onsorting = null;
227
+ try {
228
+ onsorting = /\bawait\b/.test(onsortingCode)
229
+ ? new Function(`return (async function(evt) { ${onsortingCode} })`)()
230
+ : new Function('evt', onsortingCode);
231
+ } catch (error) {
232
+ console.error('Error in onsorting compilation:', error);
233
+ }
234
+ if (onsorting) {
235
+ options.onMove = function(evt) {
236
+ if (!record.admitted) return;
237
+ try {
238
+ const answer = onsorting.call(sortableElem, evt);
239
+ return answer instanceof Promise ? undefined : answer;
240
+ } catch (error) {
241
+ console.error('Error in onsorting execution:', error);
242
+ }
243
+ };
244
+ }
229
245
  }
230
246
 
231
247
  // After a drop, run any author onsorted code, then notify reactive libraries.
@@ -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
  }