@panphora/clayjs 1.15.2 → 1.16.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 (506) hide show
  1. package/README.md +4 -5
  2. package/THIRD-PARTY-NOTICES.md +16 -0
  3. package/compat/highlight-11.9.0.min.js +1213 -0
  4. package/compat/highlight-LICENSE +29 -0
  5. package/compat/journal-assets.lock.json +22 -0
  6. package/compat/overtype-2.6.1.min.js +116 -0
  7. package/compat/overtype-LICENSE +21 -0
  8. package/dist/all.js +90 -0
  9. package/dist/clay-dom.js +84 -0
  10. package/dist/clay-events.js +103 -0
  11. package/dist/clay-internals.js +85 -0
  12. package/dist/clay-options.js +93 -0
  13. package/dist/clay-router.js +98 -0
  14. package/dist/clay-ui.js +95 -0
  15. package/dist/clay-utils.js +84 -0
  16. package/dist/clay.js +146 -0
  17. package/dist/clay.standalone.js +83090 -58706
  18. package/dist/m/attrs/add-from-T5LJXFFI.js +1 -0
  19. package/dist/m/attrs/onaftersave-OKSWEJQF.js +1 -0
  20. package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +1 -0
  21. package/dist/m/attrs/save-freeze-FUP4Q4X3.js +1 -0
  22. package/dist/m/c-2AZDZWCK.js +1 -0
  23. package/dist/m/c-2JL6E75N.js +1 -0
  24. package/dist/m/c-2M73252O.js +1 -0
  25. package/dist/m/c-2SPVQSHC.js +1 -0
  26. package/dist/m/c-33HUYEDI.js +1 -0
  27. package/dist/m/c-33JJ6CBX.js +2 -0
  28. package/dist/m/c-35NBGMZB.js +1 -0
  29. package/dist/m/c-3GJ4SDE2.js +1 -0
  30. package/dist/m/c-3IQRECYS.js +1 -0
  31. package/dist/m/c-3OWDMNJR.js +2 -0
  32. package/dist/m/c-46XYM55L.js +1 -0
  33. package/dist/m/c-4B6H4G5V.js +1 -0
  34. package/dist/m/c-4DRMS4QG.js +9 -0
  35. package/dist/m/c-4FFHZM32.js +1 -0
  36. package/dist/m/c-4JZDZFF6.js +1 -0
  37. package/dist/m/c-4L3BTFRK.js +1 -0
  38. package/dist/m/c-4Q2XTQ2N.js +2 -0
  39. package/dist/m/c-4WKFKEKF.js +1 -0
  40. package/dist/m/c-4YSCRICO.js +1 -0
  41. package/dist/m/c-54RCDCBU.js +1 -0
  42. package/dist/m/c-5M4YEKCX.js +4 -0
  43. package/dist/m/c-5R4PTRG2.js +1 -0
  44. package/dist/m/c-5V3MOVJT.js +1 -0
  45. package/dist/m/c-5VYOWDTI.js +1 -0
  46. package/dist/m/c-5Y6TNX5W.js +34 -0
  47. package/dist/m/c-5YIHJ3TJ.js +1 -0
  48. package/dist/m/c-66O32MV3.js +28 -0
  49. package/dist/m/c-6HMB24EV.js +1 -0
  50. package/dist/m/c-7DJDKPGL.js +1 -0
  51. package/dist/m/c-7O6AFR65.js +1 -0
  52. package/dist/m/c-7SWMYKAI.js +1 -0
  53. package/dist/m/c-7VGZ7RU5.js +1 -0
  54. package/dist/m/c-A7LS5UZW.js +1 -0
  55. package/dist/m/c-ANQTJ3B5.js +2 -0
  56. package/dist/m/c-AQLFEATB.js +1 -0
  57. package/dist/m/c-AYVOREBE.js +1 -0
  58. package/dist/m/c-B2ZXPOS3.js +3 -0
  59. package/dist/m/c-BBVGTQG7.js +1 -0
  60. package/dist/m/c-BMIZKMPV.js +1 -0
  61. package/dist/m/c-BTDI4Y23.js +1 -0
  62. package/dist/m/c-BWFVSBDF.js +1 -0
  63. package/dist/m/c-BYTKL6UX.js +1 -0
  64. package/dist/m/c-CFHW5AMY.js +6 -0
  65. package/dist/m/c-CJZSLMVQ.js +1 -0
  66. package/dist/m/c-CYWLPPUS.js +1 -0
  67. package/dist/m/c-CZUSOB7V.js +1 -0
  68. package/dist/m/c-DBE6ORSI.js +1 -0
  69. package/dist/m/c-DCFSEJZG.js +1 -0
  70. package/dist/m/c-DNZ5IFM6.js +1 -0
  71. package/dist/m/c-EDHUR4MX.js +1 -0
  72. package/dist/m/c-EJTGYSLN.js +1 -0
  73. package/dist/m/c-EY6UOM54.js +1 -0
  74. package/dist/m/c-F3CKJTJS.js +1 -0
  75. package/dist/m/c-F7Y6FLPY.js +1 -0
  76. package/dist/m/c-FA4DZVTC.js +1 -0
  77. package/dist/m/c-FILLTJA7.js +1 -0
  78. package/dist/m/c-G2BUZITR.js +1 -0
  79. package/dist/m/c-GB3JTV6E.js +1 -0
  80. package/dist/m/c-GBGWXTX4.js +1 -0
  81. package/dist/m/c-GHBCCVG4.js +1 -0
  82. package/dist/m/c-GJAEGGXY.js +1 -0
  83. package/dist/m/c-GOWBDM6J.js +1 -0
  84. package/dist/m/c-GQ6F6IDK.js +1 -0
  85. package/dist/m/c-H4LG442Q.js +1 -0
  86. package/dist/m/c-HA6INT6J.js +1 -0
  87. package/dist/m/c-HPURYVZS.js +2 -0
  88. package/dist/m/c-HRDCD3WN.js +67 -0
  89. package/dist/m/c-HT33EQRN.js +1 -0
  90. package/dist/m/c-HVYXIS72.js +1 -0
  91. package/dist/m/c-IB52R237.js +1 -0
  92. package/dist/m/c-IJUK2GPW.js +1 -0
  93. package/dist/m/c-IKWNE4R4.js +1 -0
  94. package/dist/m/c-IU3IYEU6.js +1 -0
  95. package/dist/m/c-IZ2EBRAJ.js +1 -0
  96. package/dist/m/c-J7CA54IP.js +1 -0
  97. package/dist/m/c-JAX2667S.js +6 -0
  98. package/dist/m/c-KI7M4AY3.js +1 -0
  99. package/dist/m/c-KIPQJX4B.js +1 -0
  100. package/dist/m/c-KKWLDOPM.js +1 -0
  101. package/dist/m/c-KR4UW4KW.js +1 -0
  102. package/dist/m/c-KY3VJGUA.js +8 -0
  103. package/dist/m/c-L4WQSC27.js +1 -0
  104. package/dist/m/c-LFPHCXY3.js +1 -0
  105. package/dist/m/c-LR4CGEPT.js +91 -0
  106. package/dist/m/c-LRPUO2MX.js +1 -0
  107. package/dist/m/c-LTGMXXKO.js +1 -0
  108. package/dist/m/c-LXENEBV7.js +1 -0
  109. package/dist/m/c-LZJPNR2W.js +1 -0
  110. package/dist/m/c-MMUIUUZG.js +2 -0
  111. package/dist/m/c-MTQHWQC3.js +1 -0
  112. package/dist/m/c-N326R2JD.js +1 -0
  113. package/dist/m/c-N7JPUPGC.js +1 -0
  114. package/dist/m/c-NMJRXKPC.js +8 -0
  115. package/dist/m/c-NRPJEVJS.js +1 -0
  116. package/dist/m/c-NUHP2XDB.js +1 -0
  117. package/dist/m/c-NZMNXVF6.js +1 -0
  118. package/dist/m/c-OCSZ4QGN.js +1 -0
  119. package/dist/m/c-OIBUTDXI.js +1 -0
  120. package/dist/m/c-OIDRZKAF.js +1 -0
  121. package/dist/m/c-OYTFP5QN.js +1 -0
  122. package/dist/m/c-P7KNDG34.js +1 -0
  123. package/dist/m/c-PPOSCAQD.js +1 -0
  124. package/dist/m/c-PQ5LK6QE.js +993 -0
  125. package/dist/m/c-PZ4XEPUP.js +2 -0
  126. package/dist/m/c-QHIWXHRO.js +1 -0
  127. package/dist/m/c-QHWJ5ZIJ.js +1 -0
  128. package/dist/m/c-QIX5BJYN.js +1 -0
  129. package/dist/m/c-QNKREOL6.js +1 -0
  130. package/dist/m/c-QQHEX6HA.js +1 -0
  131. package/dist/m/c-R7RMANJI.js +9 -0
  132. package/dist/m/c-RI4NT75Y.js +1 -0
  133. package/dist/m/c-RM7J7BV2.js +1 -0
  134. package/dist/m/c-RUJKSUQW.js +1 -0
  135. package/dist/m/c-RVMWGXEJ.js +1 -0
  136. package/dist/m/c-S7H2DRCB.js +1 -0
  137. package/dist/m/c-S7LK4KUE.js +1 -0
  138. package/dist/m/c-SGCCC4FW.js +25 -0
  139. package/dist/m/c-SGIA4IEQ.js +188 -0
  140. package/dist/m/c-SWRJFCNN.js +1 -0
  141. package/dist/m/c-T4VO4OCB.js +1 -0
  142. package/dist/m/c-TCCA5BZN.js +2 -0
  143. package/dist/m/c-TQEMVJCS.js +1 -0
  144. package/dist/m/c-TQZ2JYHD.js +1 -0
  145. package/dist/m/c-TR6RMDQ7.js +3 -0
  146. package/dist/m/c-TRS6TM6O.js +1 -0
  147. package/dist/m/c-UGHH4MB3.js +1 -0
  148. package/dist/m/c-UKHTLWUW.js +1 -0
  149. package/dist/m/c-UL2YUY52.js +1 -0
  150. package/dist/m/c-UNN7LWCT.js +1 -0
  151. package/dist/m/c-UXBMRDZB.js +235 -0
  152. package/dist/m/c-V7PZZRG7.js +1 -0
  153. package/dist/m/c-VAK7SG37.js +1 -0
  154. package/dist/m/c-VDSS4K3Z.js +1 -0
  155. package/dist/m/c-VFX45IZQ.js +1 -0
  156. package/dist/m/c-VHNSBD2M.js +1 -0
  157. package/dist/m/c-VKEDWYDF.js +1 -0
  158. package/dist/m/c-VL52ADDR.js +1 -0
  159. package/dist/m/c-WN5AY3OZ.js +1 -0
  160. package/dist/m/c-WNYBLIOZ.js +1 -0
  161. package/dist/m/c-WRBKEJD4.js +1 -0
  162. package/dist/m/c-WWDBYXLK.js +1 -0
  163. package/dist/m/c-WYFDLITL.js +1 -0
  164. package/dist/m/c-XN6MBKI6.js +67 -0
  165. package/dist/m/c-XNNW2CWK.js +1 -0
  166. package/dist/m/c-XPELB6E5.js +1 -0
  167. package/dist/m/c-XQXZAO7N.js +1 -0
  168. package/dist/m/c-Y6USGTS5.js +5 -0
  169. package/dist/m/c-YDH7Q3NY.js +1 -0
  170. package/dist/m/c-YG2NJYS6.js +135 -0
  171. package/dist/m/c-Z36J63PP.js +1 -0
  172. package/dist/m/c-Z4VRC7GU.js +7 -0
  173. package/dist/m/c-Z6K52OLP.js +19 -0
  174. package/dist/m/c-ZDY2MDAA.js +21 -0
  175. package/dist/m/c-ZE46QDIH.js +1 -0
  176. package/dist/m/c-ZUCOKOHT.js +1 -0
  177. package/dist/m/core/admin-attrs-VGNLAUDK.js +1 -0
  178. package/dist/m/core/autosave-E4PEFPZ6.js +1 -0
  179. package/dist/m/core/conflict-notice-VQ6Y64PH.js +1 -0
  180. package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
  181. package/dist/m/core/host-permission-XN3K2IW3.js +1 -0
  182. package/dist/m/core/is-edit-mode-JXHDVGUL.js +1 -0
  183. package/dist/m/core/persist-HKHA66IY.js +3 -0
  184. package/dist/m/core/recovery-TBBSICE2.js +1 -0
  185. package/dist/m/core/save-WUCHTQBE.js +1 -0
  186. package/dist/m/core/save-core-FJVHT5M3.js +1 -0
  187. package/dist/m/core/snapshot-F3SWDZMM.js +1 -0
  188. package/dist/m/core/unsaved-warning-STKBHE6G.js +1 -0
  189. package/dist/m/dom/all-APS5ZSKP.js +1 -0
  190. package/dist/m/dom/dom-helpers-L7OSR6S4.js +1 -0
  191. package/dist/m/events/index-23DPTUT4.js +1 -0
  192. package/dist/m/internals/index-WOVAP3RU.js +1 -0
  193. package/dist/m/lib/cache-bust-CGWT6ZJI.js +1 -0
  194. package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
  195. package/dist/m/loader-HXX6YNH5.js +1 -0
  196. package/dist/m/manifest-F22DEDAC.js +1 -0
  197. package/dist/m/manifest-edit-C3C7376B.js +1 -0
  198. package/dist/m/manifest.json +3491 -0
  199. package/dist/m/options/options-2LH76JQ3.js +1 -0
  200. package/dist/m/page/editor-XUTB2HB2.js +189 -0
  201. package/dist/m/plugins/ai-edit-ZQ4CGWIP.js +6 -0
  202. package/dist/m/plugins/bind-text-NX46N5UV.js +1 -0
  203. package/dist/m/plugins/cms-HP3LSTPN.js +2293 -0
  204. package/dist/m/plugins/demo-ZIBFAA3R.js +1 -0
  205. package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
  206. package/dist/m/plugins/indicator-PV4YW2ID.js +1 -0
  207. package/dist/m/plugins/live-NUTTXSBS.js +1 -0
  208. package/dist/m/plugins/menu-HCKO3DSP.js +138 -0
  209. package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
  210. package/dist/m/plugins/page-KSUH7G6J.js +2 -0
  211. package/dist/m/plugins/page-view-W73OW7WI.js +1 -0
  212. package/dist/m/plugins/people-AJJ32YCG.js +1 -0
  213. package/dist/m/plugins/quickcrop-QNY3PKLD.js +26 -0
  214. package/dist/m/plugins/quickcrop-QNY3PKLD.js.LEGAL.txt +7 -0
  215. package/dist/m/plugins/richclay-RC3L2F6X.js +517 -0
  216. package/dist/m/plugins/richclay-RC3L2F6X.js.LEGAL.txt +1 -0
  217. package/dist/m/plugins/sortable-YRMQX3ZU.js +1 -0
  218. package/dist/m/plugins/source-KHK5UUSK.js +1 -0
  219. package/dist/m/plugins/undo-OF2L3CXU.js +1 -0
  220. package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
  221. package/dist/m/plugins/upgrade-ZNYODP5Y.js +1 -0
  222. package/dist/m/plugins/upload-XSGNILUY.js +1 -0
  223. package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +21 -0
  224. package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +1 -0
  225. package/dist/m/plugins/wire-ZWVWKF76.js +1 -0
  226. package/dist/m/reader-core-ILWLXVMP.js +68 -0
  227. package/dist/m/route/route-KOCZSEGN.js +1 -0
  228. package/dist/m/sync/live-sync-4AUVXVJU.js +1 -0
  229. package/dist/m/ui/index-EVHKMKBL.js +1 -0
  230. package/dist/m/utils/index-4TJCVLT7.js +1 -0
  231. package/dist/m/vendor/Sortable.vendor-A755ITLN.js +3 -0
  232. package/dist/m/vendor/Sortable.vendor-A755ITLN.js.LEGAL.txt +1 -0
  233. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +6 -0
  234. package/dist/offline-manifest.json +3800 -0
  235. package/dist/page/themes/light-1.css +406 -0
  236. package/dist/server/core.cjs +23662 -22860
  237. package/dist/server/offline-runtime.cjs +4954 -0
  238. package/dist/server/offline-runtime.mjs +4923 -0
  239. package/dist/server/rooms.mjs +9 -0
  240. package/docs/guide.md +1078 -669
  241. package/entries/all.js +2 -0
  242. package/entries/clay-data.js +1 -1
  243. package/entries/clay-dom.js +2 -0
  244. package/entries/clay-events.js +2 -0
  245. package/entries/clay-internals.js +2 -0
  246. package/entries/clay-options.js +2 -0
  247. package/entries/clay-router.js +2 -0
  248. package/entries/clay-ui.js +2 -0
  249. package/entries/clay-utils.js +2 -0
  250. package/entries/clay.js +2 -0
  251. package/entries/sap.js +6 -6
  252. package/fonts/ibm-plex-mono-LICENSE +93 -0
  253. package/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
  254. package/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  255. package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  256. package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  257. package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  258. package/fonts/ibm-plex-sans-LICENSE +93 -0
  259. package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  260. package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  261. package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  262. package/fonts/ibm-plex.css +71 -0
  263. package/fonts/inter-LICENSE +93 -0
  264. package/fonts/inter-cyrillic-ext-wght-normal.woff2 +0 -0
  265. package/fonts/inter-cyrillic-wght-normal.woff2 +0 -0
  266. package/fonts/inter-greek-ext-wght-normal.woff2 +0 -0
  267. package/fonts/inter-greek-wght-normal.woff2 +0 -0
  268. package/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  269. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  270. package/fonts/inter-vietnamese-wght-normal.woff2 +0 -0
  271. package/fonts/inter.css +62 -0
  272. package/fonts/newsreader-LICENSE +93 -0
  273. package/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
  274. package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  275. package/fonts/newsreader-vietnamese-opsz-normal.woff2 +0 -0
  276. package/fonts/newsreader.css +26 -0
  277. package/fonts/work-sans-LICENSE +93 -0
  278. package/fonts/work-sans-latin-wght-normal.woff2 +0 -0
  279. package/fonts/work-sans.css +8 -0
  280. package/package.json +20 -3
  281. package/packed-contract.json +1 -0
  282. package/src/attrs/add-from.js +27 -4
  283. package/src/attrs/save-freeze.js +7 -52
  284. package/src/core/admin-attrs.js +119 -12
  285. package/src/core/admin-contenteditable.js +12 -10
  286. package/src/core/admin-inputs.js +14 -15
  287. package/src/core/admin-onclick.js +2 -0
  288. package/src/core/autosave.js +74 -21
  289. package/src/core/conflict-download.js +22 -0
  290. package/src/core/conflict-notice-ui.js +1173 -0
  291. package/src/core/conflict-notice.js +140 -697
  292. package/src/core/edit-mode.js +147 -14
  293. package/src/core/etag.js +30 -12
  294. package/src/core/host-meta.js +72 -8
  295. package/src/core/host-permission.js +29 -0
  296. package/src/core/is-edit-mode.js +145 -12
  297. package/src/core/mode-key.js +125 -0
  298. package/src/core/mode-lifecycle.js +544 -0
  299. package/src/core/mode-receipt.js +30 -0
  300. package/src/core/mode-refusal.js +13 -0
  301. package/src/core/mode-resources.js +52 -0
  302. package/src/core/mode-support.js +82 -0
  303. package/src/core/mode-switch.js +259 -0
  304. package/src/core/mode-transition.js +112 -0
  305. package/src/core/page-data-write.js +305 -0
  306. package/src/core/page-data.js +26 -227
  307. package/src/core/page-format-wire.js +4 -0
  308. package/src/core/page-format.js +58 -0
  309. package/src/core/persist.js +37 -9
  310. package/src/core/preserve-authored.js +114 -0
  311. package/src/core/recovery.js +772 -0
  312. package/src/core/runtime-url.js +105 -0
  313. package/src/core/save-core.js +109 -12
  314. package/src/core/save-replace.js +72 -0
  315. package/src/core/save-size-notice.js +134 -0
  316. package/src/core/save-size.js +140 -0
  317. package/src/core/save.js +805 -118
  318. package/src/core/snapshot.js +121 -11
  319. package/src/core/source-map.js +157 -9
  320. package/src/core/unsaved-warning.js +14 -4
  321. package/src/core/upgrade-run.js +408 -0
  322. package/src/core/version.js +18 -0
  323. package/src/lib/assets.js +30 -0
  324. package/src/lib/autosave-debug-gate.js +50 -0
  325. package/src/lib/autosave-scheduler.js +141 -0
  326. package/src/lib/boot-imports.js +8 -0
  327. package/src/lib/derived.js +3 -1
  328. package/src/lib/dirty-gate.js +15 -0
  329. package/src/lib/doc-storage.js +33 -0
  330. package/src/lib/effect.js +9 -4
  331. package/src/lib/mutation.js +4 -2
  332. package/src/lib/offline-runtime.js +226 -0
  333. package/src/lib/preload.js +48 -0
  334. package/src/lib/root-attrs.js +1 -1
  335. package/src/lib/trash.js +18 -0
  336. package/src/lib/undo/capture-move.js +152 -0
  337. package/src/live/local-channel.js +61 -0
  338. package/src/live/local-lane.js +60 -9
  339. package/src/live/server-lane.js +6 -3
  340. package/src/loader-logic.js +76 -15
  341. package/src/loader.js +218 -30
  342. package/src/page/adopt-boot.js +76 -22
  343. package/src/page/adopt.js +25 -4
  344. package/src/page/awareness.js +230 -0
  345. package/src/page/blocks.js +79 -5
  346. package/src/page/blocksel.js +20 -4
  347. package/src/page/board-face.js +13 -0
  348. package/src/page/board-panel.js +5 -17
  349. package/src/page/board-style.js +2 -13
  350. package/src/page/board-view.js +1 -11
  351. package/src/page/board-views.js +17 -0
  352. package/src/page/board.js +2 -2
  353. package/src/page/boot-pack.js +26 -0
  354. package/src/page/boot.js +237 -0
  355. package/src/page/checklist.js +1 -1
  356. package/src/page/chrome.js +411 -61
  357. package/src/page/csv-import.js +17 -2
  358. package/src/page/document-title-dom.js +40 -0
  359. package/src/page/document-title-runtime.js +76 -0
  360. package/src/page/document-title.js +9 -0
  361. package/src/page/drag.js +48 -13
  362. package/src/page/drop.js +2 -1
  363. package/src/page/editor.js +330 -86
  364. package/src/page/feature-loader.js +72 -0
  365. package/src/page/features/board.js +12 -0
  366. package/src/page/features/code.js +13 -0
  367. package/src/page/features/media.js +290 -0
  368. package/src/page/features/registry.js +23 -0
  369. package/src/page/features/table.js +383 -0
  370. package/src/page/{tool-view.js → features/tool.js} +33 -2
  371. package/src/page/format-codec.js +215 -0
  372. package/src/page/format-gate.js +179 -0
  373. package/src/page/format-level.js +157 -0
  374. package/src/page/highlight-slot.js +23 -0
  375. package/src/page/highlight.js +30 -16
  376. package/src/page/history-intent.js +78 -0
  377. package/src/page/inline.js +18 -25
  378. package/src/page/items.js +52 -11
  379. package/src/page/link.js +1 -0
  380. package/src/page/markdown.js +86 -1
  381. package/src/page/media-views.js +3 -0
  382. package/src/page/media.js +33 -245
  383. package/src/page/outline-cache.js +11 -0
  384. package/src/page/placeholder.js +27 -7
  385. package/src/page/projection.js +196 -50
  386. package/src/page/reader-patch.js +306 -0
  387. package/src/page/recover.js +1 -1
  388. package/src/page/rich/page-tool.js +1 -4
  389. package/src/page/runtime-owner.js +27 -0
  390. package/src/page/save-projection.js +7 -4
  391. package/src/page/schema.js +14 -0
  392. package/src/page/table-rect-key.js +3 -0
  393. package/src/page/table-select.js +4 -3
  394. package/src/page/table-style.js +1 -1
  395. package/src/page/table-views.js +3 -0
  396. package/src/page/table.js +8 -293
  397. package/src/page/themes/light-1.css +139 -2
  398. package/src/page/themes/light-1.source.css +269 -0
  399. package/src/page/tool-capability.js +3 -0
  400. package/src/page/tool-views.js +3 -0
  401. package/src/page/view.js +390 -107
  402. package/src/page/ybind/awareness.js +172 -0
  403. package/src/page/ybind/binding.js +9 -1
  404. package/src/page/ybind/bridge.js +50 -47
  405. package/src/page/ybind/decode.js +52 -19
  406. package/src/page/ybind/doc.js +27 -2
  407. package/src/page/ybind/group.js +12 -3
  408. package/src/page/ybind/index.js +2 -0
  409. package/src/page/ybind/lineage.js +71 -0
  410. package/src/page/ybind/marks.js +25 -1
  411. package/src/page/ybind/migrate.js +48 -13
  412. package/src/page/ybind/moves.js +32 -21
  413. package/src/page/ybind/positions.js +50 -0
  414. package/src/page/ybind/provider.js +84 -22
  415. package/src/page/ybind/recovery-proof.js +191 -0
  416. package/src/page/ybind/recovery-replay.js +130 -0
  417. package/src/page/ybind/tokens.js +15 -6
  418. package/src/page/ybind/walk.js +10 -3
  419. package/src/page/ybind/writer.js +48 -4
  420. package/src/plugins/ai-edit.js +167 -32
  421. package/src/plugins/bind-text.js +218 -0
  422. package/src/plugins/cms.js +93 -0
  423. package/src/plugins/indicator.js +57 -5
  424. package/src/plugins/live.js +282 -34
  425. package/src/plugins/menu/claygoo.svg.js +1 -0
  426. package/src/plugins/menu/seo-head.js +323 -0
  427. package/src/plugins/menu/seo.js +849 -0
  428. package/src/plugins/menu.js +425 -0
  429. package/src/plugins/page-view.js +131 -0
  430. package/src/plugins/page.js +1022 -275
  431. package/src/plugins/people.js +158 -31
  432. package/src/plugins/richclay.js +119 -9
  433. package/src/plugins/sortable.js +370 -60
  434. package/src/plugins/source.js +189 -366
  435. package/src/plugins/undo.js +289 -19
  436. package/src/plugins/unsent.js +132 -0
  437. package/src/plugins/upgrade-run.js +1 -0
  438. package/src/plugins/upgrade.js +210 -0
  439. package/src/plugins/upload-task.js +571 -0
  440. package/src/plugins/upload-ui.js +265 -0
  441. package/src/plugins/upload.js +34 -153
  442. package/src/plugins/visitor-drafts/inert.js +32 -0
  443. package/src/plugins/visitor-drafts/owner-chip.js +3 -0
  444. package/src/plugins/visitor-drafts/prompt.js +2 -2
  445. package/src/plugins/visitor-drafts-owner.js +85 -0
  446. package/src/plugins/visitor-drafts.js +31 -42
  447. package/src/plugins/wire.js +82 -1
  448. package/src/reader-core.js +26 -0
  449. package/src/recovery/conflicts.js +287 -0
  450. package/src/recovery/identity.js +154 -0
  451. package/src/recovery/store.js +792 -0
  452. package/src/recovery/undo.js +257 -0
  453. package/src/server/checkpoint/index.js +68 -4
  454. package/src/server/offline-runtime/index.js +214 -0
  455. package/src/server/rooms/index.js +2 -1
  456. package/src/server/rooms/live-data-rooms.js +2 -2
  457. package/src/server/rooms/page-admission.js +33 -0
  458. package/src/server/rooms/page-lineage.js +56 -0
  459. package/src/server/rooms/page-rebase.js +11 -5
  460. package/src/server/rooms/page-state.js +2 -0
  461. package/src/server/rooms/page-write-admission.js +9 -3
  462. package/src/source/session.js +705 -0
  463. package/src/sync/conflict-revert.js +5 -4
  464. package/src/sync/conflicts-edit.js +119 -0
  465. package/src/sync/conflicts-facade.js +49 -0
  466. package/src/sync/conflicts.js +99 -1
  467. package/src/sync/editing-marker.js +37 -6
  468. package/src/sync/editing-sender.js +8 -1
  469. package/src/sync/live-sync-edit.js +63 -0
  470. package/src/sync/live-sync.js +364 -119
  471. package/src/sync/local-presence.js +187 -0
  472. package/src/sync/merge-declarations.js +10 -0
  473. package/src/sync/merge-engine.js +12 -0
  474. package/src/sync/presence-store.js +412 -0
  475. package/src/sync/presence-target.js +243 -0
  476. package/src/sync/presence.js +72 -41
  477. package/src/sync/saved-handoff.js +200 -0
  478. package/src/sync/saved-stream.js +535 -0
  479. package/src/sync/version-guard.js +92 -0
  480. package/src/sync/wire-profile.js +81 -0
  481. package/src/ui/bevel-controls.js +2 -8
  482. package/src/ui/page-scheme.js +7 -0
  483. package/src/ui/skins/cms.js +1 -1
  484. package/src/ui/theme-config.js +150 -0
  485. package/src/ui/theme.js +6 -138
  486. package/src/ui/toast-lazy.js +11 -0
  487. package/src/vendor/LICENSES-optional.md +253 -0
  488. package/src/vendor/highlight.vendor.js +3 -0
  489. package/src/vendor/hljs-bash.vendor.js +2 -0
  490. package/src/vendor/hljs-css.vendor.js +2 -0
  491. package/src/vendor/hljs-javascript.vendor.js +2 -0
  492. package/src/vendor/hljs-json.vendor.js +2 -0
  493. package/src/vendor/hljs-python.vendor.js +2 -0
  494. package/src/vendor/hljs-xml.vendor.js +2 -0
  495. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  496. package/src/vendor/hyper-html-api-upgrade.vendor.js +5 -0
  497. package/src/vendor/hyper-morph-identity.vendor.js +2 -0
  498. package/src/vendor/hyper-morph-json.vendor.js +3 -0
  499. package/src/vendor/hyper-morph.vendor.js +6 -6
  500. package/src/vendor/hyper-undo.vendor.js +1 -1
  501. package/src/vendor/hypercms.vendor.js +169 -84
  502. package/src/vendor/marked.vendor.js +68 -0
  503. package/src/vendor/overtype.vendor.js +994 -0
  504. package/src/vendor/richclay.vendor.js +17 -17
  505. package/src/vendor/sap-core.vendor.js +5 -5
  506. package/src/vendor/sap-ownership.vendor.js +11 -7
@@ -1,26 +1,23 @@
1
- import { createPageEditor } from "../page/editor.js";
2
- import { readFile, installAdoption } from "../page/adopt-boot.js";
3
- import { classifyFile } from "../page/adopt.js";
4
- import { renderFromTags } from "../page/save-projection.js";
5
- import { blocksFromYValue } from "../page/ybind/decode.js";
6
- import { createYPageBinding } from "../page/ybind/binding.js";
7
- import { authorIdentity } from "../page/ybind/author.js";
8
- import { yValueOf } from "../page/ybind/provider.js";
9
- import { seedPageFromArticle } from "../page/ybind/migrate.js";
10
- import { serializeLive } from "../live/ops.js";
1
+ import { FEATURES, featureForType } from "../page/features/registry.js";
2
+ import { mergeEngine } from "../sync/merge-engine.js";
11
3
  import { registerUnsavedState } from "../lib/unsaved-state.js";
12
- import { hostMeta, hostSupports } from "../core/host-meta.js";
13
- import { savePageForce, setLastSavedBaselines, holdAllSaves, releaseAllSaves, getLastSavedBytes } from "../core/save.js";
14
- import { captureForComparisonAndDirty, captureForSaveAndComparison } from "../core/snapshot.js";
15
- import { storeOf, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady } from "./live.js";
16
- import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
4
+ import { hostMeta } from "../core/host-meta.js";
5
+ import { savePageForce, holdAllSaves, releaseAllSaves, suspendAutosave, resumeAutosave, getLastSavedBytes } from "../core/save.js";
6
+ import { setSaveHeader } from "../core/save-core.js";
7
+ import { lastSeenEtag, representedEtag, recordEtag } from "../core/etag.js";
8
+ import { captureForSaveAndComparison } from "../core/snapshot.js";
9
+ import { storeOf, stageStore, closeStore, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady } from "./live.js";
10
+ import { serializeLive } from "../live/ops.js";
11
+ import { parseJsonRelaxed } from "../vendor/hyper-morph-json.vendor.js";
17
12
  import { saveStateFor, BLOCKED_TEXT, RETRY_TEXT, downloadPageCopy, downloadSnapshot, copyPageText } from "../page/save-states.js";
18
- import toast, { toastPersistent, dismissPersistent } from "../ui/toast.js";
19
- import { createRecoverList, focusBlock } from "../page/recover.js";
20
- import { createToolHost } from "../page/tools.js";
21
- import { createAiTool } from "../page/ai-tool.js";
22
- import { tablesFromDoc } from "../page/tool-data.js";
23
- import { capabilityAdvertised } from "../page/rich/page-tool.js";
13
+ import toast, { toastPersistent, dismissPersistent } from "../ui/toast-lazy.js";
14
+ import { claim } from "../page/runtime-owner.js";
15
+ import { authorIdentity } from "../page/ybind/author.js";
16
+ import { createInstall, disposeInstall, addDisposer, finishError, isCurrent, ownsView, runBoot, activateInput } from "../page/boot.js";
17
+ import { capabilityAdvertised } from "../page/tool-capability.js";
18
+ import { startsAtImport } from "../core/is-edit-mode.js";
19
+ import { toolViews } from "../page/tool-views.js";
20
+ import { style } from "../lib/hostile-css.js";
24
21
 
25
22
  const ARTICLE_SEL = "article[data-page]";
26
23
  const TAG_SEL = 'script[type="application/json"][merge="page"][clay~="live"]';
@@ -32,11 +29,91 @@ const REPLACED_TEXT = "This page was changed outside the editor. Reload to see i
32
29
  const TOO_LARGE_TEXT = "That change is too large to share, so it was not saved. Reload the page to keep editing.";
33
30
  const OVERWRITTEN_TEXT = "Some changes you made were replaced by a change made outside the editor.";
34
31
  const REPLACED_HOLD = "The page was changed outside the editor; reload to see it.";
32
+ const LOADING_MS = 150;
33
+ const LOADING_TEXT = "Loading the editor\u2026";
34
+ // The hold a managed session keeps between preparation and its commit: no save, no
35
+ // checkpoint and no live frame of this tab's reaches the file while the mode is still
36
+ // view. Released without replay, because a save missed here belongs to no session.
37
+ const SESSION_HOLD = "the page session has not committed yet";
35
38
 
36
39
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
37
40
 
41
+ // A tag that is not a page this release reads. It is decided from the JSON alone, so a
42
+ // page that cannot be booted says so without the page's own code.
43
+ function unreadablePage(value) {
44
+ if (!isPlainObject(value)) {
45
+ console.warn("clayjs: page plugin: the page tag is not JSON, leaving the page as saved HTML");
46
+ return "invalid-page-json";
47
+ }
48
+ if (value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
49
+ console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
50
+ return "unsupported-page-format";
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // The page's own code reaches ProseMirror and Yjs, so it is a separate download: the
56
+ // plugin chunk evaluates without either vendor, and the room join starts while it
57
+ // arrives. Every step that needs it awaits the one boot-pack import, which carries the
58
+ // page code and the parts they share in one static graph, so a module they share is
59
+ // never linked by two imports at once; the pack starts the editor's own download
60
+ // beside it. A step that needs it awaits pageCodeReady(); the synchronous paths read
61
+ // pageCode, which the boot has awaited before the room's state reaches them.
62
+ let pageCode = null;
63
+ let pageCodeLoading = null;
64
+ function pageCodeReady() {
65
+ pageCodeLoading ??= import("../page/boot-pack.js").then((pack) => (pageCode = pack));
66
+ return pageCodeLoading;
67
+ }
68
+
69
+ // The parts a document asks for, read off the document itself: the state a view holds
70
+ // can have gained a block the saved article never had, from the room or from an
71
+ // adoption, and input waits for those parts as it does for the saved article's.
72
+ function docFeatureIds(doc) {
73
+ const ids = new Set();
74
+ doc.forEach((node) => {
75
+ const id = featureForType(node.type.name);
76
+ if (id) ids.add(id);
77
+ });
78
+ return [...ids];
79
+ }
80
+
81
+ // A wait longer than a moment says so. One quiet line inside the article, drawn as
82
+ // editor UI so it never reaches a snapshot, and gone the moment the boot settles.
83
+ function clearLoadingStatus(inst) {
84
+ if (inst.statusTimer) {
85
+ clearTimeout(inst.statusTimer);
86
+ inst.statusTimer = null;
87
+ }
88
+ inst.statusNote?.remove();
89
+ inst.statusNote = null;
90
+ }
91
+
92
+ function armLoadingStatus(inst, article) {
93
+ clearLoadingStatus(inst);
94
+ inst.statusTimer = setTimeout(() => {
95
+ inst.statusTimer = null;
96
+ if (!isCurrent(inst)) return;
97
+ const note = article.ownerDocument.createElement("div");
98
+ note.className = "page-boot-status";
99
+ note.setAttribute("clay", "editor-ui");
100
+ note.setAttribute("sap-ignore", "");
101
+ note.setAttribute("role", "status");
102
+ style(note, [
103
+ "display:block", "margin:0 0 1em", "font:500 13px/1.6 system-ui, sans-serif",
104
+ "color:color-mix(in srgb, currentColor 55%, transparent)", "user-select:none",
105
+ ]);
106
+ note.textContent = LOADING_TEXT;
107
+ article.prepend(note);
108
+ inst.statusNote = note;
109
+ }, LOADING_MS);
110
+ }
111
+
38
112
  let generation = 0;
39
113
  let active = null;
114
+ // A boot that ends in error says so after the installation it ended is gone, so the
115
+ // result outlives the dispose that releases what that boot started.
116
+ let ended = null;
40
117
 
41
118
  // Shorter than installAdoption's 10 s hold, so an unelected room still adopts.
42
119
  export const WRITER_WAIT_MS = 8_000;
@@ -52,10 +129,6 @@ function scrollToHashBlock(view, loc = globalThis.location) {
52
129
  globalThis.requestAnimationFrame?.(() => view.nodeDOM(found)?.scrollIntoView?.({ block: "start" }));
53
130
  }
54
131
 
55
- function abandon(inst) {
56
- if (active === inst) active = null;
57
- }
58
-
59
132
  function dispatchState(state, code, message) {
60
133
  document.dispatchEvent(new CustomEvent("clay:page-save-state", { detail: { state, code, message } }));
61
134
  }
@@ -122,8 +195,8 @@ function pageReplaced(html, savedRev, mergeTagless = false) {
122
195
  }
123
196
 
124
197
  function savedRevOf(inst) {
125
- if (!inst.store) return inst.savedRev;
126
- try { return renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
198
+ if (!inst.store || !pageCode) return inst.savedRev;
199
+ try { return pageCode.renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
127
200
  }
128
201
 
129
202
  // Nothing of this tab's may reach the file again: the editor is read-only, the
@@ -149,7 +222,7 @@ function downloadTabCopy(inst) {
149
222
  const doc = new DOMParser().parseFromString(forSave, "text/html");
150
223
  const articles = doc.querySelectorAll(ARTICLE_SEL);
151
224
  if (articles.length !== 1) return downloadPageCopy();
152
- const rendered = renderFromTags([JSON.stringify(inst.tabValue)]);
225
+ const rendered = pageCode.renderFromTags([JSON.stringify(inst.tabValue)]);
153
226
  articles[0].innerHTML = rendered.html;
154
227
  if (rendered.rev == null) articles[0].removeAttribute("data-rev");
155
228
  else articles[0].setAttribute("data-rev", rendered.rev);
@@ -185,6 +258,8 @@ function listen(inst, type, handler) {
185
258
  function listenForSaves(inst) {
186
259
  listen(inst, "clay:save-error", (event) => {
187
260
  const code = event.detail?.code ?? null;
261
+ // The host holds exactly what this tab would have written: nothing to report.
262
+ if (code === "page-nothing-to-write") return;
188
263
  applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
189
264
  });
190
265
  listen(inst, "clay:save-offline", (event) => {
@@ -195,6 +270,9 @@ function listenForSaves(inst) {
195
270
  const code = event.detail?.code ?? null;
196
271
  applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
197
272
  });
273
+ const onRefusedFirstWrite = (event) => { retryFirstWrite(inst, event); };
274
+ listen(inst, "clay:save-conflict", onRefusedFirstWrite);
275
+ listen(inst, "clay:save-error", onRefusedFirstWrite);
198
276
  listen(inst, "clay:save-saved", (event) => {
199
277
  applySaveState(inst, saveStateFor({ ok: true }), null, event.detail?.msg ?? "");
200
278
  inst.savedRev = savedRevOf(inst);
@@ -212,14 +290,56 @@ function listenForSaves(inst) {
212
290
  });
213
291
  }
214
292
 
293
+ // The page the host stores now, read once: the article a first write is offered to
294
+ // again, and the version it is offered against.
295
+ async function readStoredArticle() {
296
+ const response = await fetch(new URL(globalThis.location.href).href, { cache: "no-store" });
297
+ if (!response?.ok) return null;
298
+ const html = await response.text();
299
+ const doc = new DOMParser().parseFromString(html, "text/html");
300
+ const article = doc.querySelector(ARTICLE_SEL);
301
+ if (!article) return null;
302
+ return { article, etag: response.headers?.get?.("etag") || null };
303
+ }
304
+
305
+ // A refused first write: the stored document has moved on, or it does not descend
306
+ // from the seed this tab staged. The stored document is read once, the seed is rebuilt
307
+ // from its article, this tab's own pending edit replays over it, and the write goes
308
+ // out once more against the version just read. A second refusal keeps the text in the
309
+ // tab and shows the conflict, exactly as a lone refusal does.
310
+ async function retryFirstWrite(inst, event) {
311
+ const staged = inst.staged;
312
+ if (!staged || inst.staleRetry || inst.replaced) return;
313
+ const refusal = event?.detail?.code ?? null;
314
+ if (refusal !== "page-lineage-stale" && refusal !== "conflict") return;
315
+ if (!inst.store || !staged.isPending()) return;
316
+ inst.staleRetry = true;
317
+ try {
318
+ const found = await readStoredArticle();
319
+ if (!found || !isCurrent(inst)) return;
320
+ const page = await pageCodeReady();
321
+ if (!isCurrent(inst)) return;
322
+ const seed = page.seedPageFromArticle(found.article.cloneNode(true));
323
+ // This tab's next write is conditional on the version it has just read.
324
+ const at = found.etag ?? event?.detail?.etag ?? null;
325
+ if (at) recordEtag(at);
326
+ // The tab's own edit replays over the seed the stored article states; an op the
327
+ // stored article cannot hold is dropped by the store, never written over it.
328
+ inst.store.rebase(seed, { source: "state" });
329
+ await savePageForce();
330
+ } catch (error) {
331
+ console.warn("clayjs: page plugin could not offer the refused first write again", error);
332
+ }
333
+ }
334
+
215
335
  function restoreArticle(inst) {
216
336
  const article = inst.article;
217
337
  if (!article) return;
218
338
  let html = inst.bootHtml;
219
339
  let rev = inst.bootRev;
220
- if (inst.store) {
340
+ if (inst.store && pageCode) {
221
341
  try {
222
- const rendered = renderFromTags([JSON.stringify(inst.store.value)]);
342
+ const rendered = pageCode.renderFromTags([JSON.stringify(inst.store.value)]);
223
343
  html = rendered.html;
224
344
  rev = rendered.rev;
225
345
  } catch (error) {
@@ -231,306 +351,931 @@ function restoreArticle(inst) {
231
351
  else article.setAttribute("data-rev", rev);
232
352
  }
233
353
 
354
+ // What a mounted editor installs besides its view: the document listeners that
355
+ // follow saves and replacements, the room's subscription to it, its unsaved
356
+ // participant, the recovery list, the tool views and this install's claim on the
357
+ // article. A boot that ends without an editor releases the same resources here, so
358
+ // nothing stays wired to a view that is gone.
359
+ function releaseMounted(inst) {
360
+ for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
361
+ inst.listeners = [];
362
+ try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
363
+ inst.adoption = null;
364
+ try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
365
+ inst.recover = null;
366
+ inst.aiTool?.dispose();
367
+ inst.aiTool = null;
368
+ inst.awareness?.dispose();
369
+ inst.awareness = null;
370
+ inst.toolHost?.dispose();
371
+ inst.toolHost = null;
372
+ if (inst.view) toolViews.delete(inst.view);
373
+ if (inst.article) {
374
+ const shell = inst.article.querySelector(".page-shell");
375
+ if (shell) shell.remove();
376
+ }
377
+ try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
378
+ try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
379
+ try { inst.staged?.close(); } catch (error) { console.error("clayjs: page plugin staged store failed to close", error); }
380
+ inst.staged = null;
381
+ if (inst.pageStore) {
382
+ try { closeStore("page", inst.pageStore); } catch (error) { console.error("clayjs: page plugin store failed to close", error); }
383
+ inst.pageStore = null;
384
+ }
385
+ inst.binding = null;
386
+ inst.flush = null;
387
+ inst.releaseArticle?.();
388
+ inst.releaseArticle = null;
389
+ }
390
+
391
+ // Adoption did not land, so this tab must not hold a view of its own making: the
392
+ // mounted editor and everything it subscribed are released, its shell leaves the
393
+ // article, and the article comes back from the state the room accepted (or the
394
+ // source captured before the mount). The editor's own DOM is never read back into
395
+ // the page.
396
+ function abandonEditor(inst) {
397
+ const view = inst.view;
398
+ if (view && !view.isDestroyed && typeof view.destroy === "function") {
399
+ try { view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
400
+ }
401
+ inst.titleObserver?.dispose();
402
+ inst.titleObserver = null;
403
+ clearLoadingStatus(inst);
404
+ releaseMounted(inst);
405
+ restoreArticle(inst);
406
+ }
407
+
234
408
  function dispose() {
235
409
  generation += 1;
236
410
  const inst = active;
237
411
  active = null;
412
+ setSaveHeader("Page-Write", null);
238
413
  if (!inst) return;
414
+ disposeInstall(inst);
239
415
  if (inst.retryTimer) {
240
416
  clearTimeout(inst.retryTimer);
241
417
  inst.retryTimer = null;
242
418
  }
243
- for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
244
- inst.listeners = [];
245
419
  dismissPersistent(RETRY_TEXT);
246
420
  dismissPersistent(BLOCKED_TEXT);
247
421
  dismissPersistent(REPLACED_TEXT);
422
+ for (const message of inst.notices?.values() ?? []) dismissPersistent(message);
248
423
  if (inst.savesHeld) {
249
424
  inst.savesHeld = false;
250
425
  releaseAllSaves({ replay: false, reason: REPLACED_HOLD });
251
426
  }
252
- try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
253
- try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
254
- try { inst.view?.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
255
- inst.aiTool?.dispose();
256
- inst.toolHost?.dispose();
257
- if (inst.article) {
258
- const shell = inst.article.querySelector(".page-shell");
259
- if (shell) shell.remove();
427
+ inst.titleObserver?.dispose();
428
+ inst.titleObserver = null;
429
+ clearLoadingStatus(inst);
430
+ // A session torn down from here rather than by its own lease gives back the holds
431
+ // it took, so nothing it closed stays closed.
432
+ if (inst.sessionHeld) releaseAllSaves({ replay: false, reason: SESSION_HOLD });
433
+ if (inst.autosaveSuspended) resumeAutosave();
434
+ if (inst.view && !inst.view.isDestroyed) {
435
+ try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
260
436
  }
261
- try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
262
- try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
437
+ releaseMounted(inst);
263
438
  restoreArticle(inst);
264
439
  }
265
440
 
266
- async function run(inst, gen, options = {}) {
267
- const articles = document.querySelectorAll(ARTICLE_SEL);
268
- let tags = document.querySelectorAll(TAG_SEL);
269
- if (!articles.length && !tags.length) return abandon(inst);
270
- // A page saved without its merge state (HTML Clay, an outside edit, a download)
271
- // is booted from its article alone: the seed is deterministic, so every tab
272
- // rebuilding the same article holds the same state and their edits merge.
273
- const seeded = articles.length === 1 && tags.length === 0;
274
- if (seeded) {
275
- const saved = readFile(document);
276
- if (!saved) return abandon(inst);
277
- const tag = document.createElement("script");
278
- tag.setAttribute("type", "application/json");
279
- tag.setAttribute("merge", "page");
280
- tag.setAttribute("clay", "live");
281
- tag.textContent = serializeLive(seedPageFromArticle(saved.article));
282
- articles[0].before(tag);
283
- tags = document.querySelectorAll(TAG_SEL);
284
- }
285
- if (articles.length !== 1 || tags.length !== 1) {
286
- console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
287
- return abandon(inst);
288
- }
441
+ export function createSteps(inst, options) {
442
+ let meta = null;
443
+ let binding = null;
444
+ let editorView = null;
445
+ let editorReadyResolve = null;
446
+ let identity = null;
447
+ let features = null;
448
+ let featuresReady = null;
449
+ let savedContent = [];
450
+ let toolsStore = null;
451
+ let aiTool = null;
452
+ let toolsOpening = null;
453
+ let chrome = null;
454
+ let setEditorActive = null;
289
455
 
290
- const article = articles[0];
291
- const file = readFile(document);
292
- if (!file) return abandon(inst);
293
- let value = null;
294
- try { value = HyperMorph.parseJsonRelaxed(tags[0].textContent); } catch (error) { value = null; }
295
- if (!isPlainObject(value) || value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
296
- console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
297
- return abandon(inst);
456
+ // The view parts install onto the editor, which exists only after the mount; an
457
+ // install that starts before it waits here, and a disposed install releases it.
458
+ const editorReady = new Promise((resolve) => { editorReadyResolve = resolve; });
459
+ inst.editorReadyResolve = editorReadyResolve;
460
+ addDisposer(inst, () => editorReadyResolve?.());
461
+ addDisposer(inst, () => clearLoadingStatus(inst));
462
+
463
+ // A document with a tool block, at boot or when one arrives, opens the store its
464
+ // tools keep state in and the host that mounts them. A document with no tool
465
+ // never waits for either.
466
+ const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
467
+ const openTools = () => {
468
+ if (toolsOpening) return toolsOpening;
469
+ toolsOpening = (async () => {
470
+ // A tool written into the article by hand or by an agent arrives without the
471
+ // store its state lives in, and without one the tool could not keep its state:
472
+ // the page gets the empty store an editor-made page starts with.
473
+ if (!document.querySelector(TOOLS_SEL)) {
474
+ const tools = document.createElement("script");
475
+ tools.setAttribute("type", "application/json");
476
+ tools.setAttribute("merge", "page.tools");
477
+ tools.setAttribute("clay", "live");
478
+ tools.textContent = "{}";
479
+ document.head.appendChild(tools);
480
+ }
481
+ if (document.querySelector(TOOLS_SEL)) {
482
+ try {
483
+ toolsStore = window.clay.live("page.tools");
484
+ await toolsStore.ready;
485
+ } catch (error) {
486
+ console.warn("clayjs: page plugin could not open the tool store", error);
487
+ }
488
+ }
489
+ if (!isCurrent(inst)) return null;
490
+ const [{ createToolHost }, { tablesFromDoc }] = await Promise.all([
491
+ import("../page/tools.js"),
492
+ import("../page/tool-data.js"),
493
+ ]);
494
+ if (!isCurrent(inst)) return null;
495
+ const toolHost = createToolHost({
496
+ store: toolsStore, mode: "edit",
497
+ tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
498
+ });
499
+ inst.toolHost = toolHost;
500
+ if (capabilityAdvertised(meta)) {
501
+ const { createAiTool } = await import("../page/ai-tool.js");
502
+ if (!isCurrent(inst)) return null;
503
+ const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
504
+ aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
505
+ inst.aiTool = aiTool;
506
+ }
507
+ return toolHost;
508
+ })().catch((error) => {
509
+ toolsOpening = null;
510
+ throw error;
511
+ });
512
+ return toolsOpening;
513
+ };
514
+ // One notice per feature a person needed and that failed, until Retry succeeds.
515
+ const notices = new Map();
516
+ const notifyFailed = (id) => {
517
+ if (notices.has(id)) return;
518
+ const message = `The ${id} controls could not load. Reload the page after saving.`;
519
+ notices.set(id, message);
520
+ toastPersistent(message, "warning", {
521
+ actions: [{
522
+ label: "Retry",
523
+ onClick: () => {
524
+ features.ensure(id).then(() => {
525
+ const open = notices.get(id);
526
+ if (!open) return;
527
+ notices.delete(id);
528
+ dismissPersistent(open);
529
+ }, () => {});
530
+ },
531
+ }],
532
+ });
533
+ };
534
+ inst.notices = notices;
535
+ // Making or revising a tool opens the store and the host first, so the panel
536
+ // keeps what it makes.
537
+ const openAi = async ({ target = null, remove = null } = {}) => {
538
+ if (chrome && editorView && remove) chrome.holdSlashRange(editorView, remove);
539
+ await features.ensure("tool").catch(() => {});
540
+ if (!isCurrent(inst) || !aiTool || !editorView) return;
541
+ aiTool.open({ target, remove: remove ? chrome.heldSlashRange(editorView.state) : null });
542
+ };
543
+ const aiRequested = () => capabilityAdvertised(meta);
544
+
545
+ // The article, its page tag and the file they describe, or the reason the page
546
+ // is not edit mode's to mount. The file it found is captured here, before anything
547
+ // moves; what the tag decodes to is the page's own code's to say, so it arrives in
548
+ // source.ready while the room joins, and the join never waits for it.
549
+ function readSource(inst) {
550
+ const liveArticles = document.querySelectorAll(ARTICLE_SEL);
551
+ // The file the reader represents, when there is one, states the page this session
552
+ // edits; the live DOM is only the page an explicit edit boot found.
553
+ const represented = typeof inst.context?.represented?.bytes === "string"
554
+ ? new DOMParser().parseFromString(inst.context.represented.bytes, "text/html")
555
+ : null;
556
+ const articles = represented ? represented.querySelectorAll(ARTICLE_SEL) : liveArticles;
557
+ const tags = represented ? represented.querySelectorAll(TAG_SEL) : document.querySelectorAll(TAG_SEL);
558
+ if (!articles.length && !tags.length) return { ok: false, reason: "no-page" };
559
+ // A page saved without its merge state (HTML Clay, an outside edit, a download)
560
+ // is booted from its article alone: the seed is deterministic, so every tab
561
+ // rebuilding the same article holds the same state and their edits merge. The tag
562
+ // that states it is built here but reaches the document only for the first save
563
+ // that writes it, so a boot that ends before that save leaves no tag behind for
564
+ // the next install to read as a page the file already has.
565
+ const seeded = articles.length === 1 && tags.length === 0;
566
+ if (articles.length !== 1 || (!seeded && tags.length !== 1)) {
567
+ console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
568
+ return { ok: false, reason: "ambiguous-page" };
569
+ }
570
+ const element = liveArticles[0];
571
+ if (!element) return { ok: false, reason: "no-page" };
572
+ // The file the reader represented is the page this session edits: its tag and the
573
+ // article the seed is built from come off those bytes once, so a tag another tab
574
+ // changed and a reader's own local state cannot reach what is staged. The live
575
+ // article stays the node the editor mounts over.
576
+ const seedNode = articles[0];
577
+ const tagNode = tags[0];
578
+ // A tag this tab can read on its own is read here, so a page it cannot boot says so
579
+ // before the room is opened.
580
+ let value = null;
581
+ if (!seeded) {
582
+ try { value = parseJsonRelaxed(tagNode?.textContent); } catch (error) { value = null; }
583
+ const unreadable = unreadablePage(value);
584
+ if (unreadable) return { ok: false, reason: unreadable };
585
+ }
586
+ const source = { article: seedNode.cloneNode(true), tags: value ? [value] : null, element, blocks: null, seeded, seedValue: null };
587
+ source.ready = (async () => {
588
+ const code = await pageCodeReady();
589
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
590
+ if (seeded) {
591
+ // The seed stays a value in memory: no tag and no write until someone edits.
592
+ try {
593
+ value = source.seedValue = code.seedPageFromArticle(source.article.cloneNode(true));
594
+ } catch (error) {
595
+ console.warn("clayjs: page plugin could not rebuild the page it found, leaving the page as saved HTML", error);
596
+ return { ok: false, reason: "invalid-page-source" };
597
+ }
598
+ const unreadable = unreadablePage(value);
599
+ if (unreadable) return { ok: false, reason: unreadable };
600
+ source.tags = [value];
601
+ }
602
+ try {
603
+ source.blocks = code.blocksFromYValue(value);
604
+ } catch (error) {
605
+ console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
606
+ return { ok: false, reason: "invalid-page-json" };
607
+ }
608
+ return { ok: true };
609
+ })();
610
+ armLoadingStatus(inst, element);
611
+ return { ok: true, source };
298
612
  }
613
+
614
+ // The page store the session mounts, brought to the file the reader holds. A page store's
615
+ // value is a set of Yjs updates and this tab's own unsaved ops are updates of their own, so
616
+ // installing the file's value as the store's own leaves the unconfirmed work replayed over
617
+ // the page the file states; the binding that mounts next applies both sets in one Y
618
+ // document, which is the merge. False when the store cannot take the value, and the entry
619
+ // refuses rather than mounting a page the file does not state.
620
+ function mergeRepresentedPage(store, value) {
621
+ if (unreadablePage(value)) return false;
622
+ if (serializeLive(store.authoritative) === serializeLive(value)) return true;
299
623
  try {
300
- blocksFromYValue(value);
624
+ return store.rebase(value, { source: "state" });
301
625
  } catch (error) {
302
- console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
303
- return abandon(inst);
626
+ console.warn("clayjs: page plugin could not reconcile the open page store with the saved file", error);
627
+ return false;
304
628
  }
305
- const source = { article: file.article, tags: [value] };
629
+ }
306
630
 
307
- let meta = null;
308
- try { meta = await hostMeta(); } catch (error) { meta = null; }
309
- if (gen !== generation) return;
310
- if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
311
- showBlocked(inst, BLOCKED.code, BLOCKED.message);
312
- return;
631
+ // The page's own code, the editor it starts and the feature views the saved article
632
+ // demands are separate downloads, and the page's own starts here beside the room join.
633
+ // Nothing evaluates the editor until the coordinator calls it; the parts are
634
+ // installed at the mount, so a failed one is the loader's own Retry notice there.
635
+ async function loadEditorAssets(inst, source) {
636
+ const codeImport = pageCodeReady();
637
+ codeImport.catch(() => null);
638
+ const read = await source.ready;
639
+ if (!read.ok) return read;
640
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
641
+ const code = await codeImport;
642
+ const featureIds = [...new Set(Object.values(source.blocks).map((block) => featureForType(block.t)).filter(Boolean))];
643
+ features = code.createFeatureLoader({
644
+ load: (id) => FEATURES[id].load(),
645
+ install: async (id, mod) => {
646
+ if (!editorView) await editorReady;
647
+ if (!isCurrent(inst) || !editorView) return null;
648
+ const context = { view: editorView, upload: options.upload, board: binding.board, undo: binding.undo, redo: binding.redo, revise: aiRequested() ? (toolId, rev) => { openAi({ target: { toolId, rev } }); } : null };
649
+ if (id === "tool") {
650
+ context.toolHost = await openTools();
651
+ if (!isCurrent(inst)) return null;
652
+ }
653
+ return editor.installPageFeature(context, id, mod);
654
+ },
655
+ reportError: (error, id) => {
656
+ console.error("clayjs: an editor feature failed to load", error);
657
+ notifyFailed(id);
658
+ },
659
+ });
660
+ // The fetch only warms the module: the install waits for the mounted view, and the
661
+ // boot waits for that install before it enables input. A failure here is reported
662
+ // by that install, which offers the feature's Retry.
663
+ for (const id of featureIds) features.prepare(id).catch(() => null);
664
+ let editor = null;
665
+ try {
666
+ editor = await import("../page/editor.js");
667
+ setEditorActive = editor.setEditorActive;
668
+ chrome = { holdSlashRange: code.holdSlashRange, heldSlashRange: code.heldSlashRange };
669
+ } catch (error) {
670
+ // A host that blocked the download keeps the saved article and reads this reason.
671
+ console.error("clayjs: page plugin could not load the editor", error);
672
+ return { ok: false, reason: "editor-assets-failed" };
673
+ }
674
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
675
+ return { ok: true, editor: editor.createPageEditor, refreshView: editor.refreshMountedView, featureIds, code };
313
676
  }
314
677
 
315
- // The room was opened from a document that had no page tag, so it holds no page
316
- // store and would answer this tab's frames with unknown-store. One conditional
317
- // save of the document with the tag is the page's initial write; a tab that
318
- // loses that race reloads, which composes the state the winner initialized.
319
- inst.mergeTagless = !meta.extensions.includes("live-data");
320
- let initialSave = false;
321
- if (seeded && (await hostSupports("live-data"))) {
322
- if (gen !== generation) return;
323
- const result = await savePageForce();
324
- if (gen !== generation) return;
325
- if (!result?.ok) {
326
- if (result?.status === 412) {
327
- window.location.reload();
328
- return;
329
- }
330
- showBlocked(inst, result?.code ?? null, result?.msg ?? "");
331
- return;
678
+ async function prepareAuthority(inst, source) {
679
+ // The page's own code, with the editor it starts and the parts they share, is one
680
+ // download: it starts here, beside authority, and only a page that must rebuild
681
+ // its tag waits for it before the join. Nothing else on this path touches a vendor.
682
+ const codePromise = pageCodeReady();
683
+ try { meta = await hostMeta(); } catch (error) { meta = null; }
684
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
685
+ if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
686
+ showBlocked(inst, BLOCKED.code, BLOCKED.message);
687
+ return { ok: false, reason: "missing-capability" };
332
688
  }
333
- initialSave = true;
334
- }
335
689
 
336
- let handle;
337
- try {
338
- handle = window.clay.live("page");
339
- } catch (error) {
340
- console.warn("clayjs: page plugin could not open the page store", error);
341
- return abandon(inst);
342
- }
343
- await handle.ready;
344
- if (gen !== generation) return;
345
- const store = storeOf("page");
346
- if (!store) return abandon(inst);
347
- inst.store = store;
348
-
349
- let flushFailed = false;
350
- let registered = null;
351
- const trackFlush = async (call) => {
352
- const result = await call();
353
- flushFailed = result?.ok === false;
354
- registered?.changed();
355
- return result;
356
- };
357
- const identity = authorIdentity();
358
- const binding = createYPageBinding({
359
- store,
360
- onStopped: (reason) => {
361
- inst.tabValue = yValueOf(binding.ydoc);
362
- if (reason === "lineage") {
363
- fastLane()?.abandon?.("page");
364
- showReplaced(inst);
690
+ // A host that keeps no page data keeps its files untagged, and every one of them
691
+ // is merged by the boot instead of being read as a replacement.
692
+ inst.mergeTagless = !meta.extensions.includes("live-data");
693
+ // The one declaration that lets a host answer a seed-only body with nothing-to-write.
694
+ setSaveHeader("Page-Write", "v1");
695
+ // A seeded page is the one boot whose join needs the page code, which rebuilds
696
+ // the seed the article states.
697
+ let read = null;
698
+ let code = null;
699
+ if (source.seeded) {
700
+ read = await source.ready;
701
+ if (!read.ok) return read;
702
+ code = await codePromise;
703
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
704
+ }
705
+ // A tagless document has no page store for a room to hold, so its own is staged:
706
+ // the seed in memory and no write until a person edits. A tagged document opens
707
+ // the state the file already has.
708
+ let handle;
709
+ // Whether this session's own value is the store's: only a store the application
710
+ // opened itself keeps a page of its own.
711
+ let ownsStore = true;
712
+ try {
713
+ if (source.seeded) {
714
+ const staged = stageStore("page", {
715
+ value: source.seedValue,
716
+ represented: options.represented ?? { bytes: source.article.outerHTML, etag: representedEtag() ?? lastSeenEtag() },
717
+ });
718
+ inst.staged = staged;
719
+ handle = staged.handle;
365
720
  } else {
366
- showTooLarge(inst);
721
+ const opened = storeOf("page") === null;
722
+ ownsStore = opened;
723
+ handle = window.clay.live("page");
724
+ if (opened) inst.pageStore = storeOf("page");
367
725
  }
368
- },
369
- authorToken: identity.token,
370
- ownedTokens: identity.owned,
371
- checkpointWithin,
372
- requestCheckpoint,
373
- registerUnsavedState: (def) => {
374
- registered = registerUnsavedState({
375
- id: def.id,
376
- isPending: () => flushFailed || def.isPending(),
377
- flush: () => trackFlush(() => def.flush()),
378
- });
379
- return registered;
380
- },
381
- });
382
- inst.binding = binding;
383
- inst.flush = (options) => trackFlush(() => binding.flush(options));
384
- listenForSaves(inst);
385
-
386
- inst.source = source;
387
- inst.article = article;
388
- inst.bootHtml = source.article.innerHTML;
389
- inst.bootRev = source.article.getAttribute("data-rev");
390
- inst.savedRev = inst.bootRev;
391
- listen(inst, "clay:sync-applied", (event) => {
392
- if (inst.replaced) return;
393
- if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
394
- showReplaced(inst);
395
- });
726
+ } catch (error) {
727
+ console.warn("clayjs: page plugin could not open the page store", error);
728
+ return { ok: false, reason: "missing-page-store" };
729
+ }
730
+ try {
731
+ await handle.ready;
732
+ } catch (error) {
733
+ console.warn("clayjs: page plugin could not join the page room", error);
734
+ return { ok: false, reason: "admission-failed" };
735
+ }
736
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
737
+ // A tag the page's own code cannot decode is the boot's reason to stop, and both
738
+ // branches report the same one, so whichever settles first does not change it.
739
+ read ??= await source.ready;
740
+ if (!read.ok) return read;
741
+ // A store the application opened itself keeps the page it was opened from, and the
742
+ // file the reader holds is the page this session edits: the store is brought to that
743
+ // file before anything mounts on it, and a store that cannot take it refuses the
744
+ // entry rather than mounting a page the file does not state. A store that has joined
745
+ // a room is the exception: the room is newer than any file, and its value stands.
746
+ const inRoom = storeOf("page")?.handle?.status?.mode === "fast";
747
+ if (!ownsStore && !inRoom && !mergeRepresentedPage(storeOf("page"), source.tags?.[0])) {
748
+ return { ok: false, reason: "stale-store" };
749
+ }
750
+ code ??= await codePromise;
751
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
752
+ const store = storeOf("page");
753
+ if (!store) return { ok: false, reason: "missing-page-store" };
754
+ inst.store = store;
396
755
 
397
- // A tool written into the article by hand or by an agent arrives without the
398
- // store its state lives in, and without one the tool could not keep its state:
399
- // the page gets the empty store an editor-made page starts with.
400
- const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
401
- if (!document.querySelector(TOOLS_SEL) && article.querySelector('section[data-type="tool"]')) {
402
- const tools = document.createElement("script");
403
- tools.setAttribute("type", "application/json");
404
- tools.setAttribute("merge", "page.tools");
405
- tools.setAttribute("clay", "live");
406
- tools.textContent = "{}";
407
- document.head.appendChild(tools);
756
+ let flushFailed = false;
757
+ let registered = null;
758
+ const trackFlush = async (call) => {
759
+ const result = await call();
760
+ flushFailed = result?.ok === false;
761
+ registered?.changed();
762
+ return result;
763
+ };
764
+ identity = authorIdentity();
765
+ binding = code.createYPageBinding({
766
+ store,
767
+ onStopped: (reason) => {
768
+ inst.tabValue = code.yValueOf(binding.ydoc);
769
+ if (reason === "lineage") {
770
+ fastLane()?.abandon?.("page");
771
+ showReplaced(inst);
772
+ } else {
773
+ showTooLarge(inst);
774
+ }
775
+ },
776
+ authorToken: identity.token,
777
+ ownedTokens: identity.owned,
778
+ checkpointWithin,
779
+ requestCheckpoint,
780
+ registerUnsavedState: (def) => {
781
+ registered = registerUnsavedState({
782
+ id: def.id,
783
+ isPending: () => flushFailed || def.isPending(),
784
+ flush: () => trackFlush(() => def.flush()),
785
+ });
786
+ return registered;
787
+ },
788
+ });
789
+ inst.binding = binding;
790
+ inst.flush = (options) => trackFlush(() => binding.flush(options));
791
+ listenForSaves(inst);
792
+ return { ok: true, store, handle };
408
793
  }
409
- let toolsStore = null;
410
- if (document.querySelector(TOOLS_SEL)) {
794
+
795
+ // Other people's carets arrive on their own: the decorations load only where a
796
+ // host offers page awareness, after the editor is mounted, so neither a reader nor
797
+ // an editor on a host without presence pays for them.
798
+ function installAwareness(inst, view) {
799
+ import("../page/awareness.js").then(async (mod) => {
800
+ if (inst.view !== view) return;
801
+ const handle = await mod.installPageAwareness({ binding: inst.binding, view });
802
+ if (!handle) return;
803
+ if (inst.view !== view || view.isDestroyed) { handle.dispose(); return; }
804
+ inst.awareness = handle;
805
+ }).catch((error) => console.error("clayjs: page plugin remote carets failed to load", error));
806
+ }
807
+
808
+ function mountAndAttach(inst, source, loaded) {
809
+ const article = source.element;
810
+ // The reader that showed this article gives its claim up here and keeps its queue,
811
+ // so a preparation that fails after this point hands the article back.
812
+ inst.context?.reader?.yieldArticle?.();
813
+ // The claim comes before this install records the article as its own to
814
+ // restore: a claim another runtime holds is refused, and neither the owner's
815
+ // article nor this install's restoration ownership may change.
411
816
  try {
412
- toolsStore = window.clay.live("page.tools");
413
- await toolsStore.ready;
817
+ inst.releaseArticle = claim(article, "editor");
414
818
  } catch (error) {
415
- console.warn("clayjs: page plugin could not open the tool store", error);
819
+ console.error("clayjs: page plugin could not claim the article", error);
820
+ return { ok: false, reason: "article-owned" };
416
821
  }
822
+ inst.source = source;
823
+ inst.article = article;
824
+ inst.bootHtml = source.article.innerHTML;
825
+ inst.bootRev = source.article.getAttribute("data-rev");
826
+ inst.savedRev = inst.bootRev;
827
+ listen(inst, "clay:sync-applied", (event) => {
828
+ if (inst.replaced) return;
829
+ if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
830
+ showReplaced(inst);
831
+ });
832
+
833
+ // A page with blocks whose view parts are still coming keeps its saved article on
834
+ // screen, and the editor out of sight, until they are here. One with none of those
835
+ // blocks has nothing to wait for, so its saved article goes now. Either way it is
836
+ // this capture, taken before anything changes, that puts the article back.
837
+ savedContent = [...article.childNodes].filter((node) => node !== inst.statusNote);
838
+ // The shell is built out of the page, in an element of the same document, so a
839
+ // creation that fails never leaves half an editor over the saved article.
840
+ const staging = article.ownerDocument.createElement("div");
841
+ let view = null;
842
+ try {
843
+ view = loaded.editor(staging, {
844
+ doc: binding.doc(),
845
+ dispatch: binding.dispatch,
846
+ history: false,
847
+ undo: binding.undo,
848
+ redo: binding.redo,
849
+ board: binding.board,
850
+ editable: false,
851
+ features,
852
+ onAiTool: aiRequested() ? (_view, remove) => { openAi({ remove }); } : null,
853
+ reviseTool: aiRequested() ? (toolId, rev) => { openAi({ target: { toolId, rev } }); } : null,
854
+ });
855
+ } catch (error) {
856
+ console.error("clayjs: page plugin could not mount the editor", error);
857
+ staging.replaceChildren();
858
+ inst.releaseArticle?.();
859
+ inst.releaseArticle = null;
860
+ inst.article = null;
861
+ return { ok: false, reason: "mount-failed" };
862
+ }
863
+ editorView = view;
864
+ inst.view = view;
865
+ const shell = view.dom.closest(".page-shell");
866
+ // A shell still waiting for its boot takes no input of any kind: a control inside
867
+ // a non-editable editor would otherwise write before the boot says ready. It is
868
+ // cleared at the one place input is enabled.
869
+ if (shell) shell.inert = true;
870
+ if (loaded.featureIds.length && shell) shell.style.visibility = "hidden";
871
+ // One synchronous move: the finished shell replaces the saved surface as a
872
+ // single change, and the article element itself stays where it is.
873
+ if (!loaded.featureIds.length) for (const node of savedContent) node.remove();
874
+ if (shell) article.appendChild(shell);
875
+ loaded.refreshView?.(view);
876
+ binding.attach(view);
877
+ installAwareness(inst, view);
878
+ editorReadyResolve();
879
+ // The parts the saved article demands install onto the mounted view now, whether
880
+ // their fetch already landed or is still on the wire; the saved article stays and
881
+ // input stays off until they are in.
882
+ featuresReady = Promise.allSettled(loaded.featureIds.map((id) => features.ensure(id)));
883
+ scrollToHashBlock(view);
884
+ inst.recover = loaded.code.createRecoverList({
885
+ binding,
886
+ mount: view.dom.closest(".page-shell") ?? article,
887
+ ownTokens: [identity.token, ...identity.owned],
888
+ onRestored: (id) => loaded.code.focusBlock(view, id),
889
+ onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
890
+ });
891
+ inst.titleObserver = loaded.code.observeDocumentTitle(view);
892
+ return { ok: true, view, featureIds: loaded.featureIds };
417
893
  }
418
- if (gen !== generation) return;
419
- let editorView = null;
420
- const toolHost = createToolHost({
421
- store: toolsStore, mode: "edit",
422
- tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
423
- });
424
- inst.toolHost = toolHost;
425
- const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
426
- if (capabilityAdvertised(meta)) {
427
- inst.aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
428
- }
429
- article.innerHTML = "";
430
- const view = createPageEditor(article, {
431
- doc: binding.doc(),
432
- dispatch: binding.dispatch,
433
- history: false,
434
- undo: binding.undo,
435
- redo: binding.redo,
436
- board: binding.board,
437
- editable: false,
438
- toolHost,
439
- onAiTool: inst.aiTool ? (_view, remove) => inst.aiTool.open({ remove }) : null,
440
- reviseTool: inst.aiTool ? (toolId, rev) => inst.aiTool.open({ target: { toolId, rev } }) : null,
441
- });
442
- editorView = view;
443
- inst.view = view;
444
- binding.attach(view);
445
- scrollToHashBlock(view);
446
- inst.recover = createRecoverList({
447
- binding,
448
- mount: view.dom.closest(".page-shell") ?? article,
449
- ownTokens: [identity.token, ...identity.owned],
450
- onRestored: (id) => focusBlock(view, id),
451
- onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
452
- });
453
894
 
454
- // A hand-edited article is adopted by the room's elected writer only. With a
455
- // lane but no writer yet, this tab claims the room (a redundant set of an
456
- // existing Yjs update: a valid nonempty frame that changes nothing) and then
457
- // waits for the election, so exactly one tab adopts.
458
- const needsAdoption = classifyFile(source.article, source.tags).kind === "adopt";
459
- const awaitWriter = async () => {
460
- const lane = await pageLaneReady();
461
- if (!lane) return;
462
- if (gen !== generation) return;
463
- if (lane.status.writer === null) {
464
- const y = store.value.y;
465
- const keys = Object.keys(y);
466
- const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
467
- const value = keys.length ? y[keys[0]] : yValueOf(binding.ydoc).y.seed;
468
- store.handle.set(["y", key], value);
895
+ // Whether the room's page state already carries the hand edit: merging the article's
896
+ // changes into the room's blocks changes nothing. It is the comparison a checkpoint
897
+ // makes before it decides the room holds an edit, run against the room's state
898
+ // instead of the saved tag, so a room a peer changed after the adoption holds the
899
+ // edit too.
900
+ async function roomHoldsArticle(inst, found) {
901
+ try {
902
+ const code = await pageCodeReady();
903
+ const { HyperMorph } = await mergeEngine();
904
+ const blocks = code.blocksFromYValue(inst.store.authoritative);
905
+ const current = code.readBack(blocks, document);
906
+ const merged = code.mergePageModels(found.baseItems, current, found.parsed, {
907
+ doc: document,
908
+ merge3: HyperMorph.merge3,
909
+ mergeJson: HyperMorph.mergeJson,
910
+ });
911
+ return code.canon(merged.items) === code.canon(current);
912
+ } catch {
913
+ return false;
469
914
  }
470
- // A lane that never reports a writer must not cost the hand edit: give up
471
- // before adoption's own bound, and adopt as the file-mode tab would.
472
- let timer;
473
- await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
474
- clearTimeout(timer);
475
- };
476
- const ready = needsAdoption ? awaitWriter() : Promise.resolve();
477
-
478
- const adoption = installAdoption({
479
- view,
480
- source,
481
- ready,
482
- isAdopter: () => {
483
- const lane = fastLane();
484
- return !lane || lane.status.writer === null ? true : lane.isWriter();
485
- },
486
- requestCheckpoint: () => checkpointWithin(0),
487
- snapshot: () => captureForSaveAndComparison().forSave,
488
- mergeTagless: inst.mergeTagless,
489
- writerReady: awaitWriter,
490
- adopted: (rev) => fastLane()?.adopted?.("page", rev, store.mark),
491
- notice: (counts) => {
492
- if (!counts.copy) return;
493
- toastPersistent(OVERWRITTEN_TEXT, "warning", {
494
- actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
915
+ }
916
+
917
+ // A tab that is not the room's writer does not adopt: the elected writer lands the
918
+ // hand edit, and it reaches this tab as a page update through the room, which the
919
+ // store hands to this tab's binding. A room that already holds the edit has nothing
920
+ // to wait for. Otherwise the saved article stays up and takes no typing until the
921
+ // writer's adoption arrives, and the wait is bounded like adoption itself.
922
+ async function awaitWriterAdoption(inst, found) {
923
+ const code = await pageCodeReady();
924
+ if (await roomHoldsArticle(inst, found)) return { ok: true, outcome: "writer-adopts" };
925
+ return new Promise((resolve) => {
926
+ let off = null;
927
+ let timer = null;
928
+ let done = false;
929
+ const finish = (result) => {
930
+ if (done) return;
931
+ done = true;
932
+ if (timer) clearTimeout(timer);
933
+ timer = null;
934
+ off?.();
935
+ off = null;
936
+ resolve(result);
937
+ };
938
+ addDisposer(inst, () => finish({ ok: false, cancelled: true }));
939
+ off = inst.store.onRebase((event) => {
940
+ if (event.source === "remote") finish({ ok: true, outcome: "writer-adopts" });
495
941
  });
496
- },
497
- });
498
- inst.adoption = adoption;
499
-
500
- await adoption.idle();
501
- if (gen !== generation) return;
502
- // A replacement that landed while the page was still booting keeps the editor
503
- // read-only: the notice is already up, and this tab must not edit the old article.
504
- if (inst.replaced) {
505
- stopEditing(inst);
506
- return;
942
+ timer = setTimeout(() => {
943
+ console.warn("page: gave up waiting for the writer\u0027s adoption; the hand edit was not adopted");
944
+ finish({ ok: false, reason: "adoption-timeout" });
945
+ }, code.ADOPTION_TIMEOUT_MS);
946
+ });
947
+ }
948
+
949
+ async function adoptSource(inst, source, mounted) {
950
+ const code = await pageCodeReady();
951
+ // A hand-edited article is adopted by the room's elected writer only. With a
952
+ // lane but no writer yet, this tab claims the room (a redundant set of an
953
+ // existing Yjs update: a valid nonempty frame that changes nothing) and then
954
+ // waits for the election, so exactly one tab adopts.
955
+ const found = code.classifyFile(source.article, source.tags);
956
+ const needsAdoption = found.kind === "adopt";
957
+ const awaitWriter = async () => {
958
+ const lane = await pageLaneReady();
959
+ if (!lane) return;
960
+ if (!isCurrent(inst)) return;
961
+ if (lane.status.writer === null) {
962
+ const y = inst.store.value.y;
963
+ const keys = Object.keys(y);
964
+ const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
965
+ const value = keys.length ? y[keys[0]] : code.yValueOf(binding.ydoc).y.seed;
966
+ inst.store.handle.set(["y", key], value);
967
+ // The session is not admitted until the commit, so its ops stay in the tab: the
968
+ // claim alone is framed now, before adoption writes anything, or no election runs.
969
+ lane.release("page");
970
+ }
971
+ // A lane that never reports a writer must not cost the hand edit: give up
972
+ // before adoption's own bound, and adopt as the file-mode tab would.
973
+ let timer;
974
+ await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
975
+ clearTimeout(timer);
976
+ };
977
+ const ready = needsAdoption ? awaitWriter() : Promise.resolve();
978
+
979
+ const adoption = code.installAdoption({
980
+ view: mounted.view,
981
+ source,
982
+ ready,
983
+ isAdopter: () => {
984
+ const lane = fastLane();
985
+ return !lane || lane.status.writer === null ? true : lane.isWriter();
986
+ },
987
+ requestCheckpoint: () => checkpointWithin(0),
988
+ snapshot: () => captureForSaveAndComparison().forSave,
989
+ mergeTagless: inst.mergeTagless,
990
+ writerReady: awaitWriter,
991
+ adopted: (rev) => fastLane()?.adopted?.("page", rev, inst.store.mark),
992
+ notice: (counts) => {
993
+ if (!counts.copy) return;
994
+ toastPersistent(OVERWRITTEN_TEXT, "warning", {
995
+ actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
996
+ });
997
+ },
998
+ });
999
+ inst.adoption = adoption;
1000
+
1001
+ let outcome = await adoption.idle();
1002
+ if (!outcome) return { ok: true, outcome: "unchanged" };
1003
+ // Another tab is the room's writer, so this tab's adoption is the one that tab
1004
+ // lands: the wait ends when it reaches this tab, or as the timeout a refusal is.
1005
+ if (outcome.outcome === "writer-adopts") outcome = await awaitWriterAdoption(inst, found);
1006
+ // Adoption refused, timed out or failed: the room's state is the page's, not the
1007
+ // view this tab built over the saved article, so the editor goes and the article
1008
+ // comes back from what the room accepted. A disposed install restores nothing:
1009
+ // dispose already did.
1010
+ if (!outcome.ok && isCurrent(inst)) abandonEditor(inst);
1011
+ return outcome;
1012
+ }
1013
+
1014
+ // Everything input waits for, checked without turning anything on: the parts the
1015
+ // saved article demands, any part the document gained while the page booted, and this
1016
+ // installation's own view still in the article it claimed.
1017
+ async function prepareInput(inst, mounted) {
1018
+ // A replacement that landed while the page was still booting keeps the editor
1019
+ // read-only: the notice is already up, and this tab must not edit the old article.
1020
+ if (inst.replaced) return { ok: false, reason: "page-replaced" };
1021
+ await featuresReady;
1022
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
1023
+ // The gate: this installation's own view, still in the article it claimed, over
1024
+ // the binding the room accepted. Anything else is not something to type into.
1025
+ const shell = mounted.view.dom.closest(".page-shell");
1026
+ if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
1027
+ return { ok: false, reason: "view-detached" };
1028
+ }
1029
+ // The document the view holds can ask for a part the saved article never did: a
1030
+ // peer put a table in the room, or a hand edit spliced one in during adoption.
1031
+ // Those are the last parts input waits for, before the article gives way.
1032
+ const booted = new Set(mounted.featureIds ?? []);
1033
+ const late = docFeatureIds(mounted.view.state.doc).filter((id) => !booted.has(id));
1034
+ if (late.length) {
1035
+ await Promise.allSettled(late.map((id) => features.ensure(id)));
1036
+ if (!isCurrent(inst)) return { ok: false, cancelled: true };
1037
+ }
1038
+ return { ok: true };
507
1039
  }
508
- view.setProps({ editable: () => true });
509
-
510
- // That initial write advanced the saved baselines from a capture taken before
511
- // the editor mounted, which is what the load-time settle would otherwise absorb
512
- // boot churn into. Install them once more from the booted page, so a tab that
513
- // was opened and left alone does not read as unsaved.
514
- if (initialSave) {
515
- const { forComparison, forDirty } = captureForComparisonAndDirty();
516
- setLastSavedBaselines(forComparison, forDirty);
1040
+
1041
+ // The one place the saved article gives way to the editor, and the only place input
1042
+ // opens. It changes activation only, and only after preparation already passed, so
1043
+ // the commit that enters edit cannot fail here.
1044
+ function openInput(inst, mounted) {
1045
+ const shell = mounted.view.dom.closest(".page-shell");
1046
+ const showEditor = () => {
1047
+ for (const node of savedContent) node.remove();
1048
+ if (shell) shell.style.visibility = "";
1049
+ };
1050
+ // A replacement that landed during the feature wait keeps the editor read-only,
1051
+ // exactly as one that landed before it does.
1052
+ if (inst.replaced) {
1053
+ showEditor();
1054
+ stopEditing(inst);
1055
+ return { ok: false, reason: "page-replaced" };
1056
+ }
1057
+ if (inst.inputOpen) return { ok: true };
1058
+ if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
1059
+ return { ok: false, reason: "view-detached" };
1060
+ }
1061
+ showEditor();
1062
+ shell.inert = false;
1063
+ setEditorActive?.(mounted.view, true);
1064
+ inst.inputOpen = true;
1065
+ clearLoadingStatus(inst);
1066
+ return { ok: true };
517
1067
  }
1068
+
1069
+ // A refused or unfinished exit closes admission again without touching a buffer, so
1070
+ // a `resume` puts the very same session back.
1071
+ function closeInput(inst) {
1072
+ if (!inst.inputOpen) return false;
1073
+ setEditorActive?.(inst.view, false);
1074
+ return true;
1075
+ }
1076
+
1077
+ function resumeInput(inst) {
1078
+ if (!inst.inputOpen) return false;
1079
+ setEditorActive?.(inst.view, true);
1080
+ return true;
1081
+ }
1082
+
1083
+ // The legacy boot: preparation and the commit are one, because there is no
1084
+ // coordinator to wait for.
1085
+ async function enableInput(inst, mounted) {
1086
+ const prepared = await prepareInput(inst, mounted);
1087
+ if (!prepared.ok) return prepared;
1088
+ return openInput(inst, mounted);
1089
+ }
1090
+
1091
+ // A boot that ends without an editor gives up what its branches acquired: the room's
1092
+ // subscription, its save participant and its listeners. A boot that mounted a view
1093
+ // releases its own through the paths above and keeps the editor on screen.
1094
+ const release = (inst) => {
1095
+ if (inst.view) return;
1096
+ abandonEditor(inst);
1097
+ };
1098
+
1099
+ const steps = { readSource, loadEditorAssets, prepareAuthority, mountAndAttach, adoptSource, prepareInput, openInput, closeInput, resumeInput, enableInput, release };
1100
+ inst.steps = steps;
1101
+ return steps;
518
1102
  }
519
1103
 
520
- function install(options = {}) {
1104
+ async function run(inst, options = {}, { activate = true } = {}) {
1105
+ return runBoot(inst, createSteps(inst, options), { activate });
1106
+ }
1107
+
1108
+ // One installation, and the boot it runs. A managed session is the same installation
1109
+ // with its commit left to the coordinator: `activate: false` stops the boot where the
1110
+ // editor is staged and still takes no input.
1111
+ function begin(options = {}, { activate = true, context = null } = {}) {
521
1112
  dispose();
1113
+ ended = null;
522
1114
  const gen = ++generation;
523
- const inst = { gen, article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, replaced: false, savesHeld: false, flush: null, listeners: [], retryTimer: null, retryShown: false };
1115
+ const inst = {
1116
+ ...createInstall({
1117
+ gen,
1118
+ isCurrent: () => gen === generation && (context?.isCurrent?.() ?? true),
1119
+ onPhase: (result) => {
1120
+ // A settled boot says so by taking the waiting line away, whatever it settled
1121
+ // as: there is nothing left to wait for.
1122
+ if (result.phase === "ready" || result.phase === "blocked" || result.phase === "error") clearLoadingStatus(inst);
1123
+ options.onPhase?.(result);
1124
+ },
1125
+ }),
1126
+ article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, staged: null, pageStore: null, releaseArticle: null, replaced: false, savesHeld: false, flush: null, listeners: [], retryTimer: null, retryShown: false, statusTimer: null, statusNote: null, notices: null, editorReadyResolve: null, context, inputOpen: false, sessionHeld: false, autosaveSuspended: false, staleRetry: false,
1127
+ };
524
1128
  active = inst;
525
- return run(inst, gen, options).catch((error) => {
1129
+ const boot = run(inst, options, { activate }).catch((error) => {
526
1130
  console.error("clayjs: page plugin failed to install", error);
1131
+ ended = finishError(inst, "boot-failed");
527
1132
  if (active === inst) dispose();
1133
+ return ended;
528
1134
  });
1135
+ return { inst, boot };
1136
+ }
1137
+
1138
+ function install(options = {}) {
1139
+ return begin(options).boot;
1140
+ }
1141
+
1142
+ // What a session's own end gives back, whether or not the coordinator still calls the
1143
+ // page: its view, its listeners, its binding and its staged store go, and the article
1144
+ // comes back from the page the host accepted rather than from the editor's own DOM.
1145
+ function endSession(inst) {
1146
+ if (active === inst) {
1147
+ active = null;
1148
+ generation += 1;
1149
+ }
1150
+ setSaveHeader("Page-Write", null);
1151
+ disposeInstall(inst);
1152
+ if (inst.retryTimer) {
1153
+ clearTimeout(inst.retryTimer);
1154
+ inst.retryTimer = null;
1155
+ }
1156
+ inst.titleObserver?.dispose();
1157
+ inst.titleObserver = null;
1158
+ clearLoadingStatus(inst);
1159
+ if (inst.view && !inst.view.isDestroyed) {
1160
+ try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
1161
+ }
1162
+ inst.view = null;
1163
+ releaseMounted(inst);
1164
+ restoreArticle(inst);
1165
+ }
1166
+
1167
+ // The save hold a managed session takes between its preparation and its commit: given
1168
+ // back once, by the enable that commits it or by the preparation that fails.
1169
+ function releaseSessionHold(inst) {
1170
+ if (!inst.sessionHeld) return;
1171
+ inst.sessionHeld = false;
1172
+ releaseAllSaves({ replay: false, reason: SESSION_HOLD });
529
1173
  }
530
1174
 
1175
+ // What both roads hold between the commit that enters edit and the one that leaves it:
1176
+ // a refused exit is `quiesce` then `resume`, and the committed exit is `dispose`. Only a
1177
+ // session preparation holds takes a save hold to give back; an adopted one took none.
1178
+ function sessionLease(inst, { held = true } = {}) {
1179
+ return {
1180
+ // Synchronous after preparation: it opens admission, input and the lane's writes,
1181
+ // and nothing it does can fail. An adopted session is already open.
1182
+ enable() {
1183
+ if (!held) return;
1184
+ releaseSessionHold(inst);
1185
+ activateInput(inst);
1186
+ },
1187
+ quiesce() {
1188
+ // A composition in flight is not this session's to cut: the exit is refused, and
1189
+ // the text stays where the person is typing it.
1190
+ if (inst.view?.composing) {
1191
+ throw Object.assign(new Error("clayjs: a composition is still in progress"), { code: "composition" });
1192
+ }
1193
+ inst.steps?.closeInput?.(inst);
1194
+ // Scheduled autosaves stop here; the buffers, the selection and the exit's own
1195
+ // flush stay exactly as they were.
1196
+ if (!inst.autosaveSuspended) {
1197
+ inst.autosaveSuspended = true;
1198
+ suspendAutosave();
1199
+ }
1200
+ },
1201
+ resume() {
1202
+ releaseSessionHold(inst);
1203
+ if (inst.autosaveSuspended) {
1204
+ inst.autosaveSuspended = false;
1205
+ resumeAutosave();
1206
+ }
1207
+ inst.steps?.resumeInput?.(inst);
1208
+ },
1209
+ dispose() {
1210
+ releaseSessionHold(inst);
1211
+ if (inst.autosaveSuspended) {
1212
+ inst.autosaveSuspended = false;
1213
+ resumeAutosave();
1214
+ }
1215
+ endSession(inst);
1216
+ },
1217
+ };
1218
+ }
1219
+
1220
+ // What the coordinator holds between the commit that enters edit and the one that
1221
+ // leaves it: preparation is `ready`, the commit is `enable`, a refused exit is
1222
+ // `quiesce` then `resume`, and the committed exit is `dispose`.
1223
+ export function preparePageSession(context = null, options = {}) {
1224
+ holdAllSaves(SESSION_HOLD);
1225
+ const { inst, boot } = begin(options, { activate: false, context });
1226
+ inst.sessionHeld = true;
1227
+ const lease = sessionLease(inst, { held: true });
1228
+ // The facade reads a session as the boot that is staging it, so it settles at the
1229
+ // staging or at the blocked outcome, and never rejects.
1230
+ page.ready = boot.then(() => page.status);
1231
+ const ready = boot.then((result) => {
1232
+ if (!result?.deferred) {
1233
+ throw Object.assign(new Error("clayjs: the page session could not prepare"), { code: result?.reason ?? "load-failed" });
1234
+ }
1235
+ return undefined;
1236
+ });
1237
+ // A preparation the coordinator never holds, and never disposes either, must not
1238
+ // keep the page's saves closed: it gives its hold back as it fails, and a reader that
1239
+ // yielded the article takes it back before the failure reaches the coordinator.
1240
+ ready.catch(() => {
1241
+ releaseSessionHold(inst);
1242
+ try { context?.reader?.reclaimArticle?.(); } catch (error) { console.error("clayjs: the reader could not take the article back", error); }
1243
+ });
1244
+ return { ready, ...lease };
1245
+ }
1246
+
1247
+ /**
1248
+ * The page as a mode participant: the editor the commit that enters edit opens, over
1249
+ * the page the host already stores, and the one the exit takes back. `prepare` stages
1250
+ * the article, the room and the editor without taking input or writing; the commit
1251
+ * enables the same session.
1252
+ */
1253
+ export function pageParticipant(options = {}) {
1254
+ return {
1255
+ id: "clay:page",
1256
+ // After live (200) and upload (340): the page builds its stores over the admission
1257
+ // those two already opened.
1258
+ order: 400,
1259
+ prepare(context = {}) {
1260
+ // An adoption is the session this tab's edit boot started: the exit takes that one
1261
+ // back, and the entry starts none.
1262
+ if (context.initial === true) {
1263
+ if (!active) return { enable() {}, quiesce() {}, resume() {}, dispose() {} };
1264
+ return sessionLease(active, { held: false });
1265
+ }
1266
+ return preparePageSession(context, options);
1267
+ },
1268
+ };
1269
+ }
1270
+
1271
+ export { pageParticipant as modeParticipant };
1272
+
531
1273
  export const page = {
532
1274
  ready: null,
533
1275
  install,
1276
+ get status() {
1277
+ return active?.result ?? ended;
1278
+ },
534
1279
  flush(options = {}) {
535
1280
  const inst = active;
536
1281
  if (!inst || !inst.flush) return Promise.resolve({ ok: false, code: "disposed" });
@@ -542,6 +1287,8 @@ export const page = {
542
1287
  },
543
1288
  };
544
1289
 
545
- page.ready = install();
1290
+ // A live entry imports this module while the committed mode is still view: the install
1291
+ // belongs to the explicit edit boot alone, and a managed session starts its own.
1292
+ page.ready = startsAtImport() ? install() : null;
546
1293
 
547
1294
  export default page;