@panphora/clayjs 1.15.2 → 1.17.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.
- package/README.md +4 -5
- package/THIRD-PARTY-NOTICES.md +16 -0
- package/compat/highlight-11.9.0.min.js +1213 -0
- package/compat/highlight-LICENSE +29 -0
- package/compat/journal-assets.lock.json +22 -0
- package/compat/overtype-2.6.1.min.js +116 -0
- package/compat/overtype-LICENSE +21 -0
- package/dist/all.js +90 -0
- package/dist/clay-dom.js +84 -0
- package/dist/clay-events.js +103 -0
- package/dist/clay-internals.js +85 -0
- package/dist/clay-options.js +93 -0
- package/dist/clay-router.js +98 -0
- package/dist/clay-ui.js +95 -0
- package/dist/clay-utils.js +84 -0
- package/dist/clay.js +146 -0
- package/dist/clay.standalone.js +84291 -57611
- package/dist/m/attrs/add-from-T5LJXFFI.js +1 -0
- package/dist/m/attrs/onaftersave-OKSWEJQF.js +1 -0
- package/dist/m/attrs/refetch-on-save-ZUE7PSCI.js +1 -0
- package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
- package/dist/m/c-2AZDZWCK.js +1 -0
- package/dist/m/c-2SPVQSHC.js +1 -0
- package/dist/m/c-2YYDMLNZ.js +1 -0
- package/dist/m/c-336SGU5W.js +1 -0
- package/dist/m/c-33JJ6CBX.js +2 -0
- package/dist/m/c-3GJ4SDE2.js +1 -0
- package/dist/m/c-3H6BZDNJ.js +6 -0
- package/dist/m/c-3OWDMNJR.js +2 -0
- package/dist/m/c-3RUU4TPX.js +91 -0
- package/dist/m/c-3XIRD4NU.js +1 -0
- package/dist/m/c-46XYM55L.js +1 -0
- package/dist/m/c-4DFEJ2PO.js +3 -0
- package/dist/m/c-4JZDZFF6.js +1 -0
- package/dist/m/c-4L3BTFRK.js +1 -0
- package/dist/m/c-4Q2XTQ2N.js +2 -0
- package/dist/m/c-4WKFKEKF.js +1 -0
- package/dist/m/c-56QRZTZH.js +1 -0
- package/dist/m/c-5J4E7O4Y.js +4 -0
- package/dist/m/c-5LKDWQTL.js +19 -0
- package/dist/m/c-5QWQ7A5L.js +1 -0
- package/dist/m/c-5RJFTLSF.js +1 -0
- package/dist/m/c-5V3MOVJT.js +1 -0
- package/dist/m/c-5VYOWDTI.js +1 -0
- package/dist/m/c-5YIHJ3TJ.js +1 -0
- package/dist/m/c-6AZOGAPE.js +1 -0
- package/dist/m/c-6CRUUXOX.js +5 -0
- package/dist/m/c-6HMB24EV.js +1 -0
- package/dist/m/c-6KEVEQ7L.js +1 -0
- package/dist/m/c-6NC5I2TV.js +1 -0
- package/dist/m/c-74MGYWZF.js +1 -0
- package/dist/m/c-7DJDKPGL.js +1 -0
- package/dist/m/c-7OLLJPWP.js +1 -0
- package/dist/m/c-7SWMYKAI.js +1 -0
- package/dist/m/c-A3E3NIMC.js +1 -0
- package/dist/m/c-A7LS5UZW.js +1 -0
- package/dist/m/c-AMSNPOCX.js +1 -0
- package/dist/m/c-AQLFEATB.js +1 -0
- package/dist/m/c-B3JM5ZXC.js +1 -0
- package/dist/m/c-BBVGTQG7.js +1 -0
- package/dist/m/c-BPF2NSPV.js +1 -0
- package/dist/m/c-BQ6LOBEE.js +1 -0
- package/dist/m/c-BTDI4Y23.js +1 -0
- package/dist/m/c-BWFVSBDF.js +1 -0
- package/dist/m/c-BYTKL6UX.js +1 -0
- package/dist/m/c-CFHW5AMY.js +6 -0
- package/dist/m/c-CJ3A7ZKE.js +1 -0
- package/dist/m/c-CQHPGKX2.js +1 -0
- package/dist/m/c-CYWLPPUS.js +1 -0
- package/dist/m/c-CZUSOB7V.js +1 -0
- package/dist/m/c-DCFSEJZG.js +1 -0
- package/dist/m/c-DCXOXBF3.js +1 -0
- package/dist/m/c-DFS4XZJV.js +2 -0
- package/dist/m/c-DNENWY4O.js +1 -0
- package/dist/m/c-DNZ5IFM6.js +1 -0
- package/dist/m/c-E4LIR5X5.js +1 -0
- package/dist/m/c-EJTGYSLN.js +1 -0
- package/dist/m/c-EVCQ3BKA.js +1 -0
- package/dist/m/c-EZU56NQV.js +34 -0
- package/dist/m/c-FDNHHZY5.js +1 -0
- package/dist/m/c-FILLTJA7.js +1 -0
- package/dist/m/c-FRLHSY7E.js +1 -0
- package/dist/m/c-FROJUZMV.js +1 -0
- package/dist/m/c-G2JPKCTP.js +1 -0
- package/dist/m/c-GB6TU7WV.js +1 -0
- package/dist/m/c-GFJJEBTU.js +1 -0
- package/dist/m/c-GJAEGGXY.js +1 -0
- package/dist/m/c-GOWBDM6J.js +1 -0
- package/dist/m/c-GQ6F6IDK.js +1 -0
- package/dist/m/c-GSFIGKOP.js +1 -0
- package/dist/m/c-GY422N3Q.js +1 -0
- package/dist/m/c-H4LG442Q.js +1 -0
- package/dist/m/c-HA6INT6J.js +1 -0
- package/dist/m/c-HAWFNBYG.js +1 -0
- package/dist/m/c-HDDEEL77.js +1 -0
- package/dist/m/c-HFKUVACH.js +1 -0
- package/dist/m/c-HHDFCNHV.js +1 -0
- package/dist/m/c-HPURYVZS.js +2 -0
- package/dist/m/c-I2ZATQCD.js +2 -0
- package/dist/m/c-IEM4NTCS.js +1 -0
- package/dist/m/c-IJUK2GPW.js +1 -0
- package/dist/m/c-IW6GGOU2.js +1 -0
- package/dist/m/c-JAX2667S.js +6 -0
- package/dist/m/c-JDCX26UP.js +25 -0
- package/dist/m/c-JRKY44DK.js +1 -0
- package/dist/m/c-JU3NK25H.js +1 -0
- package/dist/m/c-KAU6E43D.js +1 -0
- package/dist/m/c-KBS3UV6B.js +1 -0
- package/dist/m/c-KGVGROZT.js +1 -0
- package/dist/m/c-KI7M4AY3.js +1 -0
- package/dist/m/c-KIPQJX4B.js +1 -0
- package/dist/m/c-KQ6TYJAP.js +135 -0
- package/dist/m/c-KU3ZFADW.js +1 -0
- package/dist/m/c-L4WQSC27.js +1 -0
- package/dist/m/c-LFPHCXY3.js +1 -0
- package/dist/m/c-LRPUO2MX.js +1 -0
- package/dist/m/c-LWLBHLVC.js +3 -0
- package/dist/m/c-LZJPNR2W.js +1 -0
- package/dist/m/c-M2L47S2X.js +7 -0
- package/dist/m/c-MFP2ZWD4.js +1 -0
- package/dist/m/c-MKSQ4Y57.js +1 -0
- package/dist/m/c-MTQHWQC3.js +1 -0
- package/dist/m/c-N326R2JD.js +1 -0
- package/dist/m/c-N7JPUPGC.js +1 -0
- package/dist/m/c-NRPJEVJS.js +1 -0
- package/dist/m/c-NUFFJVVJ.js +1 -0
- package/dist/m/c-NUHP2XDB.js +1 -0
- package/dist/m/c-NUHSPT5P.js +1 -0
- package/dist/m/c-O72HVYSA.js +1 -0
- package/dist/m/c-OCSZ4QGN.js +1 -0
- package/dist/m/c-OIBUTDXI.js +1 -0
- package/dist/m/c-OIDRZKAF.js +1 -0
- package/dist/m/c-OW5QIGG3.js +1 -0
- package/dist/m/c-P7KNDG34.js +1 -0
- package/dist/m/c-PGRCK73F.js +1 -0
- package/dist/m/c-PQ5LK6QE.js +993 -0
- package/dist/m/c-PS5ARVWR.js +9 -0
- package/dist/m/c-PZ4XEPUP.js +2 -0
- package/dist/m/c-Q622NS2Y.js +1 -0
- package/dist/m/c-Q7NQD65V.js +1 -0
- package/dist/m/c-QH63LQOS.js +1 -0
- package/dist/m/c-QHIWXHRO.js +1 -0
- package/dist/m/c-QHWJ5ZIJ.js +1 -0
- package/dist/m/c-QICP2YR5.js +1 -0
- package/dist/m/c-QJ4OM3G6.js +1 -0
- package/dist/m/c-QNKREOL6.js +1 -0
- package/dist/m/c-R3L7ZJGB.js +2 -0
- package/dist/m/c-R7IWVINQ.js +1 -0
- package/dist/m/c-RM7J7BV2.js +1 -0
- package/dist/m/c-RSMOODDU.js +1 -0
- package/dist/m/c-RVMWGXEJ.js +1 -0
- package/dist/m/c-RYUXUUBX.js +1 -0
- package/dist/m/c-S4ZEH46B.js +9 -0
- package/dist/m/c-S7H2DRCB.js +1 -0
- package/dist/m/c-S7LK4KUE.js +1 -0
- package/dist/m/c-SDAXDM2D.js +1 -0
- package/dist/m/c-SGIA4IEQ.js +188 -0
- package/dist/m/c-SMBZG5X4.js +1 -0
- package/dist/m/c-SMGU7RT3.js +235 -0
- package/dist/m/c-SPIORRYS.js +1 -0
- package/dist/m/c-SWLRT66R.js +1 -0
- package/dist/m/c-SZGYW5HT.js +1 -0
- package/dist/m/c-TCCA5BZN.js +2 -0
- package/dist/m/c-TCJTVAFN.js +1 -0
- package/dist/m/c-TJ6WOJL3.js +67 -0
- package/dist/m/c-TQEMVJCS.js +1 -0
- package/dist/m/c-TRS6TM6O.js +1 -0
- package/dist/m/c-TYLXX4NF.js +21 -0
- package/dist/m/c-U62OB4BT.js +1 -0
- package/dist/m/c-U6YWUAR3.js +8 -0
- package/dist/m/c-UDCSU5G5.js +2 -0
- package/dist/m/c-UDUUJPFZ.js +1 -0
- package/dist/m/c-UGHH4MB3.js +1 -0
- package/dist/m/c-UL2YUY52.js +1 -0
- package/dist/m/c-VAK7SG37.js +1 -0
- package/dist/m/c-VDSS4K3Z.js +1 -0
- package/dist/m/c-VE6ZMYMC.js +1 -0
- package/dist/m/c-VG5XIN4T.js +1 -0
- package/dist/m/c-VHM5XQHK.js +1 -0
- package/dist/m/c-VHNSBD2M.js +1 -0
- package/dist/m/c-VI5RMSVY.js +1 -0
- package/dist/m/c-VKEDWYDF.js +1 -0
- package/dist/m/c-VRZN56XY.js +1 -0
- package/dist/m/c-W5MCHEGA.js +1 -0
- package/dist/m/c-WDC5GVKU.js +1 -0
- package/dist/m/c-WN5AY3OZ.js +1 -0
- package/dist/m/c-WNYBLIOZ.js +1 -0
- package/dist/m/c-WRBKEJD4.js +1 -0
- package/dist/m/c-WWXIDH4O.js +28 -0
- package/dist/m/c-WYFDLITL.js +1 -0
- package/dist/m/c-XCNTK76U.js +1 -0
- package/dist/m/c-XMS6DPBR.js +1 -0
- package/dist/m/c-XN6MBKI6.js +67 -0
- package/dist/m/c-XPELB6E5.js +1 -0
- package/dist/m/c-XQXZAO7N.js +1 -0
- package/dist/m/c-Y5HBBIQ4.js +1 -0
- package/dist/m/c-ZE46QDIH.js +1 -0
- package/dist/m/c-ZUCOKOHT.js +1 -0
- package/dist/m/c-ZVPVXTJF.js +4 -0
- package/dist/m/c-ZVRKXKM7.js +1 -0
- package/dist/m/c-ZWIOJGMT.js +1 -0
- package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
- package/dist/m/core/autosave-HRYGUWV6.js +1 -0
- package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
- package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
- package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
- package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
- package/dist/m/core/persist-JFYRHMRJ.js +3 -0
- package/dist/m/core/recovery-FLTCC2OC.js +1 -0
- package/dist/m/core/save-HQZ4S667.js +1 -0
- package/dist/m/core/save-core-KGQWFUNH.js +1 -0
- package/dist/m/core/snapshot-EGQZNT45.js +1 -0
- package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
- package/dist/m/dom/all-APS5ZSKP.js +1 -0
- package/dist/m/dom/dom-helpers-L7OSR6S4.js +1 -0
- package/dist/m/events/index-RN2JNGBV.js +1 -0
- package/dist/m/internals/index-PVXGPUDA.js +1 -0
- package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
- package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
- package/dist/m/loader-S4I7GKXR.js +1 -0
- package/dist/m/manifest-4C419D67.js +1 -0
- package/dist/m/manifest-edit-B077F288.js +1 -0
- package/dist/m/manifest.json +3988 -0
- package/dist/m/options/options-EJMS3K24.js +1 -0
- package/dist/m/page/editor-NNJA4OKD.js +202 -0
- package/dist/m/plugins/ai-edit-QN4PMMNL.js +6 -0
- package/dist/m/plugins/bind-text-NX46N5UV.js +1 -0
- package/dist/m/plugins/cms-HP3LSTPN.js +2293 -0
- package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
- package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
- package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
- package/dist/m/plugins/live-HFWD4OIH.js +1 -0
- package/dist/m/plugins/menu-3HIEQSI6.js +138 -0
- package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
- package/dist/m/plugins/page-JOKB6XPR.js +2 -0
- package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
- package/dist/m/plugins/people-SGPJKANK.js +1 -0
- package/dist/m/plugins/quickcrop-QNY3PKLD.js +26 -0
- package/dist/m/plugins/quickcrop-QNY3PKLD.js.LEGAL.txt +7 -0
- package/dist/m/plugins/richclay-NRQDSWX6.js +517 -0
- package/dist/m/plugins/richclay-NRQDSWX6.js.LEGAL.txt +1 -0
- package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
- package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
- package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
- package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
- package/dist/m/plugins/upgrade-W7FG5VFG.js +1 -0
- package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
- package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
- package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
- package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
- package/dist/m/reader-core-DXYCURFF.js +68 -0
- package/dist/m/route/route-KOCZSEGN.js +1 -0
- package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
- package/dist/m/ui/index-3JLVI27O.js +1 -0
- package/dist/m/utils/index-4TJCVLT7.js +1 -0
- package/dist/m/vendor/Sortable.vendor-A755ITLN.js +3 -0
- package/dist/m/vendor/Sortable.vendor-A755ITLN.js.LEGAL.txt +1 -0
- package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +6 -0
- package/dist/offline-manifest.json +4034 -0
- package/dist/page/themes/light-1.css +430 -0
- package/dist/server/core.cjs +23748 -22855
- package/dist/server/offline-runtime.cjs +4954 -0
- package/dist/server/offline-runtime.mjs +4923 -0
- package/dist/server/rooms.mjs +9 -0
- package/docs/guide.md +1078 -669
- package/entries/all.js +2 -0
- package/entries/clay-data.js +1 -1
- package/entries/clay-dom.js +2 -0
- package/entries/clay-events.js +2 -0
- package/entries/clay-internals.js +2 -0
- package/entries/clay-options.js +2 -0
- package/entries/clay-router.js +2 -0
- package/entries/clay-ui.js +2 -0
- package/entries/clay-utils.js +2 -0
- package/entries/clay.js +2 -0
- package/entries/sap.js +6 -6
- package/fonts/ibm-plex-mono-LICENSE +93 -0
- package/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
- package/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
- package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/fonts/ibm-plex-sans-LICENSE +93 -0
- package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/fonts/ibm-plex.css +71 -0
- package/fonts/inter-LICENSE +93 -0
- package/fonts/inter-cyrillic-ext-wght-normal.woff2 +0 -0
- package/fonts/inter-cyrillic-wght-normal.woff2 +0 -0
- package/fonts/inter-greek-ext-wght-normal.woff2 +0 -0
- package/fonts/inter-greek-wght-normal.woff2 +0 -0
- package/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/fonts/inter-vietnamese-wght-normal.woff2 +0 -0
- package/fonts/inter.css +62 -0
- package/fonts/newsreader-LICENSE +93 -0
- package/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
- package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
- package/fonts/newsreader-vietnamese-opsz-normal.woff2 +0 -0
- package/fonts/newsreader.css +26 -0
- package/fonts/work-sans-LICENSE +93 -0
- package/fonts/work-sans-latin-wght-normal.woff2 +0 -0
- package/fonts/work-sans.css +8 -0
- package/package.json +20 -3
- package/packed-contract.json +1 -0
- package/src/attrs/add-from.js +27 -4
- package/src/attrs/save-freeze.js +7 -52
- package/src/core/admin-attrs.js +119 -12
- package/src/core/admin-contenteditable.js +12 -10
- package/src/core/admin-inputs.js +14 -15
- package/src/core/admin-onclick.js +2 -0
- package/src/core/autosave.js +74 -21
- package/src/core/boot-phase.js +2 -1
- package/src/core/conflict-download.js +19 -23
- package/src/core/conflict-notice-ui.js +1173 -0
- package/src/core/conflict-notice.js +140 -697
- package/src/core/download.js +32 -0
- package/src/core/edit-mode.js +147 -14
- package/src/core/etag.js +30 -12
- package/src/core/host-meta.js +90 -8
- package/src/core/host-permission.js +29 -0
- package/src/core/is-edit-mode.js +145 -12
- package/src/core/mode-key.js +125 -0
- package/src/core/mode-lifecycle.js +544 -0
- package/src/core/mode-receipt.js +30 -0
- package/src/core/mode-refusal.js +13 -0
- package/src/core/mode-resources.js +52 -0
- package/src/core/mode-support.js +82 -0
- package/src/core/mode-switch.js +259 -0
- package/src/core/mode-transition.js +112 -0
- package/src/core/page-data-write.js +305 -0
- package/src/core/page-data.js +26 -227
- package/src/core/page-format-wire.js +4 -0
- package/src/core/page-format.js +58 -0
- package/src/core/persist.js +37 -9
- package/src/core/preserve-authored.js +114 -0
- package/src/core/recovery.js +772 -0
- package/src/core/runtime-url.js +105 -0
- package/src/core/save-core.js +113 -12
- package/src/core/save-replace.js +72 -0
- package/src/core/save-size-notice.js +134 -0
- package/src/core/save-size.js +140 -0
- package/src/core/save.js +806 -119
- package/src/core/snapshot.js +159 -11
- package/src/core/source-map.js +157 -9
- package/src/core/unsaved-warning.js +14 -4
- package/src/core/upgrade-run.js +408 -0
- package/src/core/version.js +18 -0
- package/src/lib/assets.js +30 -0
- package/src/lib/autosave-debug-gate.js +50 -0
- package/src/lib/autosave-scheduler.js +140 -0
- package/src/lib/boot-imports.js +8 -0
- package/src/lib/derived.js +3 -1
- package/src/lib/dirty-gate.js +15 -0
- package/src/lib/doc-storage.js +33 -0
- package/src/lib/effect.js +9 -4
- package/src/lib/mutation.js +4 -2
- package/src/lib/offline-runtime.js +226 -0
- package/src/lib/preload.js +48 -0
- package/src/lib/root-attrs.js +1 -1
- package/src/lib/trash.js +18 -0
- package/src/lib/undo/capture-move.js +152 -0
- package/src/live/local-channel.js +61 -0
- package/src/live/local-lane.js +60 -9
- package/src/live/server-lane.js +6 -3
- package/src/loader-logic.js +76 -15
- package/src/loader.js +218 -30
- package/src/page/adopt-boot.js +76 -22
- package/src/page/adopt.js +25 -4
- package/src/page/awareness.js +230 -0
- package/src/page/blocks.js +113 -6
- package/src/page/blocksel.js +20 -4
- package/src/page/board-face.js +13 -0
- package/src/page/board-panel.js +5 -17
- package/src/page/board-style.js +2 -13
- package/src/page/board-view.js +1 -11
- package/src/page/board-views.js +3 -0
- package/src/page/board.js +2 -2
- package/src/page/boot-pack.js +26 -0
- package/src/page/boot.js +237 -0
- package/src/page/chart-tables.js +11 -0
- package/src/page/chart.js +6 -10
- package/src/page/checklist.js +1 -1
- package/src/page/checkpoint-proof.js +289 -0
- package/src/page/chrome.js +431 -63
- package/src/page/csv-import.js +17 -2
- package/src/page/document-title-dom.js +52 -0
- package/src/page/document-title-runtime.js +90 -0
- package/src/page/document-title.js +12 -0
- package/src/page/drag.js +48 -13
- package/src/page/drop.js +2 -1
- package/src/page/editor.js +399 -90
- package/src/page/emoji-data.js +162 -0
- package/src/page/feature-loader.js +72 -0
- package/src/page/features/board.js +21 -0
- package/src/page/features/chart.js +11 -0
- package/src/page/features/code.js +13 -0
- package/src/page/features/dates.js +180 -0
- package/src/page/features/emoji.js +187 -0
- package/src/page/features/media.js +290 -0
- package/src/page/features/mentions.js +188 -0
- package/src/page/features/registry.js +28 -0
- package/src/page/features/table.js +383 -0
- package/src/page/{tool-view.js → features/tool.js} +33 -2
- package/src/page/format-codec.js +215 -0
- package/src/page/format-command.js +9 -0
- package/src/page/format-gate.js +179 -0
- package/src/page/format-host.js +54 -0
- package/src/page/format-level.js +157 -0
- package/src/page/format-native-commands.js +142 -0
- package/src/page/format-native.js +42 -0
- package/src/page/highlight-slot.js +23 -0
- package/src/page/highlight.js +30 -16
- package/src/page/history-intent.js +78 -0
- package/src/page/inline.js +45 -28
- package/src/page/items.js +67 -14
- package/src/page/link.js +1 -0
- package/src/page/markdown.js +112 -3
- package/src/page/media-views.js +3 -0
- package/src/page/media.js +33 -245
- package/src/page/mention-picker.js +99 -0
- package/src/page/merge.js +1 -1
- package/src/page/outline-cache.js +11 -0
- package/src/page/placeholder.js +27 -7
- package/src/page/projection.js +240 -51
- package/src/page/reader-patch.js +306 -0
- package/src/page/recover.js +1 -1
- package/src/page/rich/page-tool.js +1 -4
- package/src/page/runtime-owner.js +27 -0
- package/src/page/save-projection.js +46 -4
- package/src/page/save-states.js +1 -1
- package/src/page/schema.js +47 -0
- package/src/page/table-rect-key.js +3 -0
- package/src/page/table-select.js +4 -3
- package/src/page/table-style.js +1 -1
- package/src/page/table-views.js +3 -0
- package/src/page/table.js +8 -293
- package/src/page/themes/content.css +25 -1
- package/src/page/themes/light-1.css +163 -2
- package/src/page/themes/light-1.source.css +269 -0
- package/src/page/tool-capability.js +3 -0
- package/src/page/tool-views.js +3 -0
- package/src/page/view-hooks.js +32 -0
- package/src/page/view.js +390 -107
- package/src/page/ybind/awareness.js +172 -0
- package/src/page/ybind/binding.js +40 -5
- package/src/page/ybind/bridge.js +50 -47
- package/src/page/ybind/decode.js +52 -19
- package/src/page/ybind/doc.js +27 -2
- package/src/page/ybind/group.js +12 -3
- package/src/page/ybind/index.js +2 -0
- package/src/page/ybind/lineage.js +71 -0
- package/src/page/ybind/marks.js +26 -1
- package/src/page/ybind/migrate.js +48 -13
- package/src/page/ybind/moves.js +32 -21
- package/src/page/ybind/positions.js +50 -0
- package/src/page/ybind/provider.js +97 -24
- package/src/page/ybind/recovery-proof.js +191 -0
- package/src/page/ybind/recovery-replay.js +488 -0
- package/src/page/ybind/tokens.js +15 -6
- package/src/page/ybind/walk.js +10 -3
- package/src/page/ybind/writer.js +48 -4
- package/src/plugins/ai-edit.js +167 -32
- package/src/plugins/bind-text.js +218 -0
- package/src/plugins/cms.js +93 -0
- package/src/plugins/indicator.js +57 -5
- package/src/plugins/live.js +319 -35
- package/src/plugins/menu/claygoo.svg.js +1 -0
- package/src/plugins/menu/seo-head.js +323 -0
- package/src/plugins/menu/seo.js +849 -0
- package/src/plugins/menu.js +425 -0
- package/src/plugins/page-view.js +131 -0
- package/src/plugins/page.js +1131 -270
- package/src/plugins/people.js +158 -31
- package/src/plugins/richclay.js +119 -9
- package/src/plugins/sortable.js +370 -60
- package/src/plugins/source.js +189 -366
- package/src/plugins/undo.js +289 -19
- package/src/plugins/unsent.js +132 -0
- package/src/plugins/upgrade-run.js +1 -0
- package/src/plugins/upgrade.js +210 -0
- package/src/plugins/upload-task.js +571 -0
- package/src/plugins/upload-ui.js +265 -0
- package/src/plugins/upload.js +34 -153
- package/src/plugins/visitor-drafts/inert.js +32 -0
- package/src/plugins/visitor-drafts/intro.js +2 -1
- package/src/plugins/visitor-drafts/owner-chip.js +3 -0
- package/src/plugins/visitor-drafts/prompt.js +2 -2
- package/src/plugins/visitor-drafts-owner.js +85 -0
- package/src/plugins/visitor-drafts.js +31 -42
- package/src/plugins/wire.js +82 -1
- package/src/reader-core.js +26 -0
- package/src/recovery/conflicts.js +287 -0
- package/src/recovery/identity.js +154 -0
- package/src/recovery/store.js +792 -0
- package/src/recovery/undo.js +257 -0
- package/src/server/checkpoint/index.js +68 -4
- package/src/server/offline-runtime/index.js +214 -0
- package/src/server/rooms/index.js +2 -1
- package/src/server/rooms/live-data-rooms.js +2 -2
- package/src/server/rooms/page-admission.js +33 -0
- package/src/server/rooms/page-lineage.js +56 -0
- package/src/server/rooms/page-rebase.js +11 -5
- package/src/server/rooms/page-state.js +2 -0
- package/src/server/rooms/page-write-admission.js +9 -3
- package/src/source/session.js +705 -0
- package/src/sync/conflict-revert.js +5 -4
- package/src/sync/conflicts-edit.js +119 -0
- package/src/sync/conflicts-facade.js +49 -0
- package/src/sync/conflicts.js +99 -1
- package/src/sync/editing-marker.js +37 -6
- package/src/sync/editing-sender.js +8 -1
- package/src/sync/live-sync-edit.js +63 -0
- package/src/sync/live-sync.js +364 -119
- package/src/sync/local-presence.js +187 -0
- package/src/sync/merge-declarations.js +10 -0
- package/src/sync/merge-engine.js +12 -0
- package/src/sync/presence-store.js +412 -0
- package/src/sync/presence-target.js +243 -0
- package/src/sync/presence.js +72 -41
- package/src/sync/saved-handoff.js +200 -0
- package/src/sync/saved-stream.js +544 -0
- package/src/sync/version-guard.js +92 -0
- package/src/sync/wire-profile.js +81 -0
- package/src/ui/bevel-controls.js +2 -8
- package/src/ui/page-scheme.js +7 -0
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/theme-config.js +150 -0
- package/src/ui/theme.js +6 -138
- package/src/ui/toast-lazy.js +11 -0
- package/src/vendor/LICENSES-optional.md +253 -0
- package/src/vendor/highlight.vendor.js +3 -0
- package/src/vendor/hljs-bash.vendor.js +2 -0
- package/src/vendor/hljs-css.vendor.js +2 -0
- package/src/vendor/hljs-javascript.vendor.js +2 -0
- package/src/vendor/hljs-json.vendor.js +2 -0
- package/src/vendor/hljs-python.vendor.js +2 -0
- package/src/vendor/hljs-xml.vendor.js +2 -0
- package/src/vendor/hyper-html-api-data.vendor.js +1 -1
- package/src/vendor/hyper-html-api-upgrade.vendor.js +5 -0
- package/src/vendor/hyper-morph-identity.vendor.js +2 -0
- package/src/vendor/hyper-morph-json.vendor.js +3 -0
- package/src/vendor/hyper-morph.vendor.js +6 -6
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +169 -84
- package/src/vendor/marked.vendor.js +68 -0
- package/src/vendor/overtype.vendor.js +994 -0
- package/src/vendor/richclay.vendor.js +17 -17
- package/src/vendor/sap-core.vendor.js +5 -5
- package/src/vendor/sap-ownership.vendor.js +11 -7
package/src/plugins/page.js
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
13
|
-
import { savePageForce,
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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, registerPageCheckpoints } 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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
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,1045 @@ 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);
|
|
413
|
+
registerPageCheckpoints(null);
|
|
238
414
|
if (!inst) return;
|
|
415
|
+
disposeInstall(inst);
|
|
239
416
|
if (inst.retryTimer) {
|
|
240
417
|
clearTimeout(inst.retryTimer);
|
|
241
418
|
inst.retryTimer = null;
|
|
242
419
|
}
|
|
243
|
-
for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
|
|
244
|
-
inst.listeners = [];
|
|
245
420
|
dismissPersistent(RETRY_TEXT);
|
|
246
421
|
dismissPersistent(BLOCKED_TEXT);
|
|
247
422
|
dismissPersistent(REPLACED_TEXT);
|
|
423
|
+
for (const message of inst.notices?.values() ?? []) dismissPersistent(message);
|
|
248
424
|
if (inst.savesHeld) {
|
|
249
425
|
inst.savesHeld = false;
|
|
250
426
|
releaseAllSaves({ replay: false, reason: REPLACED_HOLD });
|
|
251
427
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (inst.
|
|
258
|
-
|
|
259
|
-
|
|
428
|
+
inst.titleObserver?.dispose();
|
|
429
|
+
inst.titleObserver = null;
|
|
430
|
+
clearLoadingStatus(inst);
|
|
431
|
+
// A session torn down from here rather than by its own lease gives back the holds
|
|
432
|
+
// it took, so nothing it closed stays closed.
|
|
433
|
+
if (inst.sessionHeld) releaseAllSaves({ replay: false, reason: SESSION_HOLD });
|
|
434
|
+
if (inst.autosaveSuspended) resumeAutosave();
|
|
435
|
+
if (inst.view && !inst.view.isDestroyed) {
|
|
436
|
+
try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
|
|
260
437
|
}
|
|
261
|
-
|
|
262
|
-
try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
|
|
438
|
+
releaseMounted(inst);
|
|
263
439
|
restoreArticle(inst);
|
|
264
440
|
}
|
|
265
441
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
}
|
|
442
|
+
// The page's journal, its replay and the proof that retires it, all imported the first
|
|
443
|
+
// time a page can actually journal: a reader, a page without a pinned identity and a host
|
|
444
|
+
// with no recovery service pay for none of it.
|
|
445
|
+
let recoveryModules = null;
|
|
289
446
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
447
|
+
function pageRecovery() {
|
|
448
|
+
if (!recoveryModules) {
|
|
449
|
+
recoveryModules = Promise.all([import("../page/ybind/recovery-replay.js"), import("../page/checkpoint-proof.js")])
|
|
450
|
+
.then(([replay, proof]) => ({ replay, proof }))
|
|
451
|
+
.catch((error) => {
|
|
452
|
+
console.warn("clayjs: page journal could not load", error);
|
|
453
|
+
return null;
|
|
454
|
+
});
|
|
298
455
|
}
|
|
456
|
+
return recoveryModules;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// What this session journals under, or null when this page cannot pin an identity or this
|
|
460
|
+
// host has no recovery service: the document is then edited with no local copy, as before.
|
|
461
|
+
async function openJournal(meta, store) {
|
|
462
|
+
const service = globalThis.window?.clay?.recovery ?? null;
|
|
463
|
+
if (!service || !store) return null;
|
|
464
|
+
const modules = await pageRecovery();
|
|
465
|
+
if (!modules) return null;
|
|
299
466
|
try {
|
|
300
|
-
|
|
467
|
+
return modules.replay.openPageJournal({
|
|
468
|
+
meta,
|
|
469
|
+
service,
|
|
470
|
+
base: modules.replay.journalBase(store.value),
|
|
471
|
+
});
|
|
301
472
|
} catch (error) {
|
|
302
|
-
console.warn("clayjs: page
|
|
303
|
-
return
|
|
473
|
+
console.warn("clayjs: page journal could not open", error);
|
|
474
|
+
return null;
|
|
304
475
|
}
|
|
305
|
-
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Replay once, after the authority snapshot is adopted and before input starts. A capsule
|
|
479
|
+
// this page may not replay is kept and named for review; nothing of it is applied.
|
|
480
|
+
async function replayJournal(inst) {
|
|
481
|
+
const journal = inst.binding?.journal;
|
|
482
|
+
if (!journal || !inst.store) return { replayed: [], review: [] };
|
|
483
|
+
const modules = await pageRecovery();
|
|
484
|
+
if (!modules) return { replayed: [], review: [] };
|
|
485
|
+
const outcome = await modules.replay.replayInto({ journal, state: inst.store.value, into: inst.binding.ydoc });
|
|
486
|
+
// The recovered page is this tab's page now: it is offered to the room exactly as an
|
|
487
|
+
// edit would be, and it is not one more journal and not one giant undo step.
|
|
488
|
+
if (outcome.replayed.length) inst.binding.provider.settle();
|
|
489
|
+
if (outcome.review.length) inst.recoveryReview = outcome.review;
|
|
490
|
+
return outcome;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// One accepted save retires the records a proof covers, and no others.
|
|
494
|
+
function installCheckpointProof(inst) {
|
|
495
|
+
registerPageCheckpoints(async ({ capture, state, article, etag }) => {
|
|
496
|
+
const journal = inst.binding?.journal;
|
|
497
|
+
if (!journal) return;
|
|
498
|
+
const modules = await pageRecovery();
|
|
499
|
+
const context = modules ? await journal.context() : null;
|
|
500
|
+
if (!context) return;
|
|
501
|
+
// Where this host serves the page state, if it serves one at all: asked here, because
|
|
502
|
+
// a page that journals nothing never needs the answer.
|
|
503
|
+
const { hostPageStateHref } = await import("../core/host-meta.js");
|
|
504
|
+
const answer = await modules.proof.checkpointAccepted({
|
|
505
|
+
channel: journal.channel,
|
|
506
|
+
records: journal.records(),
|
|
507
|
+
capture,
|
|
508
|
+
accepted: context,
|
|
509
|
+
article,
|
|
510
|
+
state,
|
|
511
|
+
etag,
|
|
512
|
+
href: await hostPageStateHref(),
|
|
513
|
+
});
|
|
514
|
+
if (answer.ok) journal.retire(answer.retired);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
306
517
|
|
|
518
|
+
export function createSteps(inst, options) {
|
|
307
519
|
let meta = null;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
520
|
+
let binding = null;
|
|
521
|
+
let editorView = null;
|
|
522
|
+
let editorReadyResolve = null;
|
|
523
|
+
let identity = null;
|
|
524
|
+
let features = null;
|
|
525
|
+
let featuresReady = null;
|
|
526
|
+
let savedContent = [];
|
|
527
|
+
let toolsStore = null;
|
|
528
|
+
let aiTool = null;
|
|
529
|
+
let toolsOpening = null;
|
|
530
|
+
let chrome = null;
|
|
531
|
+
let setEditorActive = null;
|
|
532
|
+
|
|
533
|
+
// The view parts install onto the editor, which exists only after the mount; an
|
|
534
|
+
// install that starts before it waits here, and a disposed install releases it.
|
|
535
|
+
const editorReady = new Promise((resolve) => { editorReadyResolve = resolve; });
|
|
536
|
+
inst.editorReadyResolve = editorReadyResolve;
|
|
537
|
+
addDisposer(inst, () => editorReadyResolve?.());
|
|
538
|
+
addDisposer(inst, () => clearLoadingStatus(inst));
|
|
314
539
|
|
|
315
|
-
// The
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
540
|
+
// The format gate, fetched when a newer-format command first asks for it: neither
|
|
541
|
+
// the gate nor its host adapter is on the editor's ready path. One gate per session,
|
|
542
|
+
// built against this editor installation, and disposed with the editor.
|
|
543
|
+
let formatGate = null;
|
|
544
|
+
let formatGateLoading = null;
|
|
545
|
+
inst.loadFormatGate = () => {
|
|
546
|
+
if (formatGate) return Promise.resolve(formatGate);
|
|
547
|
+
formatGateLoading ??= Promise.all([import("../page/format-gate.js"), import("../page/format-host.js")])
|
|
548
|
+
.then(([{ createFormatGate }, { createFormatHost }]) => {
|
|
549
|
+
const host = createFormatHost();
|
|
550
|
+
formatGate = createFormatGate({
|
|
551
|
+
host,
|
|
552
|
+
supported: host.supported,
|
|
553
|
+
installed: () => identity?.token ?? null,
|
|
554
|
+
revision: () => null,
|
|
555
|
+
});
|
|
556
|
+
return formatGate;
|
|
557
|
+
})
|
|
558
|
+
.catch((error) => {
|
|
559
|
+
formatGateLoading = null;
|
|
560
|
+
throw error;
|
|
561
|
+
});
|
|
562
|
+
return formatGateLoading;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// A document with a tool block, at boot or when one arrives, opens the store its
|
|
566
|
+
// tools keep state in and the host that mounts them. A document with no tool
|
|
567
|
+
// never waits for either.
|
|
568
|
+
const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
|
|
569
|
+
const openTools = () => {
|
|
570
|
+
if (toolsOpening) return toolsOpening;
|
|
571
|
+
toolsOpening = (async () => {
|
|
572
|
+
// A tool written into the article by hand or by an agent arrives without the
|
|
573
|
+
// store its state lives in, and without one the tool could not keep its state:
|
|
574
|
+
// the page gets the empty store an editor-made page starts with.
|
|
575
|
+
if (!document.querySelector(TOOLS_SEL)) {
|
|
576
|
+
const tools = document.createElement("script");
|
|
577
|
+
tools.setAttribute("type", "application/json");
|
|
578
|
+
tools.setAttribute("merge", "page.tools");
|
|
579
|
+
tools.setAttribute("clay", "live");
|
|
580
|
+
tools.textContent = "{}";
|
|
581
|
+
document.head.appendChild(tools);
|
|
329
582
|
}
|
|
330
|
-
|
|
331
|
-
|
|
583
|
+
if (document.querySelector(TOOLS_SEL)) {
|
|
584
|
+
try {
|
|
585
|
+
toolsStore = window.clay.live("page.tools");
|
|
586
|
+
await toolsStore.ready;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
console.warn("clayjs: page plugin could not open the tool store", error);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!isCurrent(inst)) return null;
|
|
592
|
+
const [{ createToolHost }, { tablesFromDoc }] = await Promise.all([
|
|
593
|
+
import("../page/tools.js"),
|
|
594
|
+
import("../page/tool-data.js"),
|
|
595
|
+
]);
|
|
596
|
+
if (!isCurrent(inst)) return null;
|
|
597
|
+
const toolHost = createToolHost({
|
|
598
|
+
store: toolsStore, mode: "edit",
|
|
599
|
+
tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
|
|
600
|
+
});
|
|
601
|
+
inst.toolHost = toolHost;
|
|
602
|
+
if (capabilityAdvertised(meta)) {
|
|
603
|
+
const { createAiTool } = await import("../page/ai-tool.js");
|
|
604
|
+
if (!isCurrent(inst)) return null;
|
|
605
|
+
const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
|
|
606
|
+
aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
|
|
607
|
+
inst.aiTool = aiTool;
|
|
608
|
+
}
|
|
609
|
+
return toolHost;
|
|
610
|
+
})().catch((error) => {
|
|
611
|
+
toolsOpening = null;
|
|
612
|
+
throw error;
|
|
613
|
+
});
|
|
614
|
+
return toolsOpening;
|
|
615
|
+
};
|
|
616
|
+
// One notice per feature a person needed and that failed, until Retry succeeds.
|
|
617
|
+
const notices = new Map();
|
|
618
|
+
const notifyFailed = (id) => {
|
|
619
|
+
if (notices.has(id)) return;
|
|
620
|
+
const message = `The ${id} controls could not load. Reload the page after saving.`;
|
|
621
|
+
notices.set(id, message);
|
|
622
|
+
toastPersistent(message, "warning", {
|
|
623
|
+
actions: [{
|
|
624
|
+
label: "Retry",
|
|
625
|
+
onClick: () => {
|
|
626
|
+
features.ensure(id).then(() => {
|
|
627
|
+
const open = notices.get(id);
|
|
628
|
+
if (!open) return;
|
|
629
|
+
notices.delete(id);
|
|
630
|
+
dismissPersistent(open);
|
|
631
|
+
}, () => {});
|
|
632
|
+
},
|
|
633
|
+
}],
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
inst.notices = notices;
|
|
637
|
+
// Making or revising a tool opens the store and the host first, so the panel
|
|
638
|
+
// keeps what it makes.
|
|
639
|
+
const openAi = async ({ target = null, remove = null } = {}) => {
|
|
640
|
+
if (chrome && editorView && remove) chrome.holdSlashRange(editorView, remove);
|
|
641
|
+
await features.ensure("tool").catch(() => {});
|
|
642
|
+
if (!isCurrent(inst) || !aiTool || !editorView) return;
|
|
643
|
+
aiTool.open({ target, remove: remove ? chrome.heldSlashRange(editorView.state) : null });
|
|
644
|
+
};
|
|
645
|
+
const aiRequested = () => capabilityAdvertised(meta);
|
|
646
|
+
|
|
647
|
+
// The article, its page tag and the file they describe, or the reason the page
|
|
648
|
+
// is not edit mode's to mount. The file it found is captured here, before anything
|
|
649
|
+
// moves; what the tag decodes to is the page's own code's to say, so it arrives in
|
|
650
|
+
// source.ready while the room joins, and the join never waits for it.
|
|
651
|
+
function readSource(inst) {
|
|
652
|
+
const liveArticles = document.querySelectorAll(ARTICLE_SEL);
|
|
653
|
+
// The file the reader represents, when there is one, states the page this session
|
|
654
|
+
// edits; the live DOM is only the page an explicit edit boot found.
|
|
655
|
+
const represented = typeof inst.context?.represented?.bytes === "string"
|
|
656
|
+
? new DOMParser().parseFromString(inst.context.represented.bytes, "text/html")
|
|
657
|
+
: null;
|
|
658
|
+
const articles = represented ? represented.querySelectorAll(ARTICLE_SEL) : liveArticles;
|
|
659
|
+
const tags = represented ? represented.querySelectorAll(TAG_SEL) : document.querySelectorAll(TAG_SEL);
|
|
660
|
+
if (!articles.length && !tags.length) return { ok: false, reason: "no-page" };
|
|
661
|
+
// A page saved without its merge state (HTML Clay, an outside edit, a download)
|
|
662
|
+
// is booted from its article alone: the seed is deterministic, so every tab
|
|
663
|
+
// rebuilding the same article holds the same state and their edits merge. The tag
|
|
664
|
+
// that states it is built here but reaches the document only for the first save
|
|
665
|
+
// that writes it, so a boot that ends before that save leaves no tag behind for
|
|
666
|
+
// the next install to read as a page the file already has.
|
|
667
|
+
const seeded = articles.length === 1 && tags.length === 0;
|
|
668
|
+
if (articles.length !== 1 || (!seeded && tags.length !== 1)) {
|
|
669
|
+
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`);
|
|
670
|
+
return { ok: false, reason: "ambiguous-page" };
|
|
671
|
+
}
|
|
672
|
+
const element = liveArticles[0];
|
|
673
|
+
if (!element) return { ok: false, reason: "no-page" };
|
|
674
|
+
// The file the reader represented is the page this session edits: its tag and the
|
|
675
|
+
// article the seed is built from come off those bytes once, so a tag another tab
|
|
676
|
+
// changed and a reader's own local state cannot reach what is staged. The live
|
|
677
|
+
// article stays the node the editor mounts over.
|
|
678
|
+
const seedNode = articles[0];
|
|
679
|
+
const tagNode = tags[0];
|
|
680
|
+
// A tag this tab can read on its own is read here, so a page it cannot boot says so
|
|
681
|
+
// before the room is opened.
|
|
682
|
+
let value = null;
|
|
683
|
+
if (!seeded) {
|
|
684
|
+
try { value = parseJsonRelaxed(tagNode?.textContent); } catch (error) { value = null; }
|
|
685
|
+
const unreadable = unreadablePage(value);
|
|
686
|
+
if (unreadable) return { ok: false, reason: unreadable };
|
|
332
687
|
}
|
|
333
|
-
|
|
688
|
+
const source = { article: seedNode.cloneNode(true), tags: value ? [value] : null, element, blocks: null, seeded, seedValue: null };
|
|
689
|
+
source.ready = (async () => {
|
|
690
|
+
const code = await pageCodeReady();
|
|
691
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
692
|
+
if (seeded) {
|
|
693
|
+
// The seed stays a value in memory: no tag and no write until someone edits.
|
|
694
|
+
try {
|
|
695
|
+
value = source.seedValue = code.seedPageFromArticle(source.article.cloneNode(true));
|
|
696
|
+
} catch (error) {
|
|
697
|
+
console.warn("clayjs: page plugin could not rebuild the page it found, leaving the page as saved HTML", error);
|
|
698
|
+
return { ok: false, reason: "invalid-page-source" };
|
|
699
|
+
}
|
|
700
|
+
const unreadable = unreadablePage(value);
|
|
701
|
+
if (unreadable) return { ok: false, reason: unreadable };
|
|
702
|
+
source.tags = [value];
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
source.blocks = code.blocksFromYValue(value);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
|
|
708
|
+
return { ok: false, reason: "invalid-page-json" };
|
|
709
|
+
}
|
|
710
|
+
return { ok: true };
|
|
711
|
+
})();
|
|
712
|
+
armLoadingStatus(inst, element);
|
|
713
|
+
return { ok: true, source };
|
|
334
714
|
}
|
|
335
715
|
|
|
336
|
-
|
|
716
|
+
// The page store the session mounts, brought to the file the reader holds. A page store's
|
|
717
|
+
// value is a set of Yjs updates and this tab's own unsaved ops are updates of their own, so
|
|
718
|
+
// installing the file's value as the store's own leaves the unconfirmed work replayed over
|
|
719
|
+
// the page the file states; the binding that mounts next applies both sets in one Y
|
|
720
|
+
// document, which is the merge. False when the store cannot take the value, and the entry
|
|
721
|
+
// refuses rather than mounting a page the file does not state.
|
|
722
|
+
function mergeRepresentedPage(store, value) {
|
|
723
|
+
if (unreadablePage(value)) return false;
|
|
724
|
+
if (serializeLive(store.authoritative) === serializeLive(value)) return true;
|
|
337
725
|
try {
|
|
338
|
-
|
|
726
|
+
return store.rebase(value, { source: "state" });
|
|
339
727
|
} catch (error) {
|
|
340
|
-
console.warn("clayjs: page plugin could not
|
|
341
|
-
return
|
|
728
|
+
console.warn("clayjs: page plugin could not reconcile the open page store with the saved file", error);
|
|
729
|
+
return false;
|
|
342
730
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The page's own code, the editor it starts and the feature views the saved article
|
|
734
|
+
// demands are separate downloads, and the page's own starts here beside the room join.
|
|
735
|
+
// Nothing evaluates the editor until the coordinator calls it; the parts are
|
|
736
|
+
// installed at the mount, so a failed one is the loader's own Retry notice there.
|
|
737
|
+
async function loadEditorAssets(inst, source) {
|
|
738
|
+
const codeImport = pageCodeReady();
|
|
739
|
+
codeImport.catch(() => null);
|
|
740
|
+
const read = await source.ready;
|
|
741
|
+
if (!read.ok) return read;
|
|
742
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
743
|
+
const code = await codeImport;
|
|
744
|
+
const featureIds = [...new Set(Object.values(source.blocks).map((block) => featureForType(block.t)).filter(Boolean))];
|
|
745
|
+
features = code.createFeatureLoader({
|
|
746
|
+
load: (id) => FEATURES[id].load(),
|
|
747
|
+
install: async (id, mod) => {
|
|
748
|
+
if (!editorView) await editorReady;
|
|
749
|
+
if (!isCurrent(inst) || !editorView) return null;
|
|
750
|
+
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 };
|
|
751
|
+
if (id === "tool") {
|
|
752
|
+
context.toolHost = await openTools();
|
|
753
|
+
if (!isCurrent(inst)) return null;
|
|
754
|
+
}
|
|
755
|
+
return editor.installPageFeature(context, id, mod);
|
|
756
|
+
},
|
|
757
|
+
reportError: (error, id) => {
|
|
758
|
+
console.error("clayjs: an editor feature failed to load", error);
|
|
759
|
+
notifyFailed(id);
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
// The fetch only warms the module: the install waits for the mounted view, and the
|
|
763
|
+
// boot waits for that install before it enables input. A failure here is reported
|
|
764
|
+
// by that install, which offers the feature's Retry.
|
|
765
|
+
for (const id of featureIds) features.prepare(id).catch(() => null);
|
|
766
|
+
let editor = null;
|
|
767
|
+
try {
|
|
768
|
+
editor = await import("../page/editor.js");
|
|
769
|
+
setEditorActive = editor.setEditorActive;
|
|
770
|
+
chrome = { holdSlashRange: code.holdSlashRange, heldSlashRange: code.heldSlashRange };
|
|
771
|
+
} catch (error) {
|
|
772
|
+
// A host that blocked the download keeps the saved article and reads this reason.
|
|
773
|
+
console.error("clayjs: page plugin could not load the editor", error);
|
|
774
|
+
return { ok: false, reason: "editor-assets-failed" };
|
|
775
|
+
}
|
|
776
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
777
|
+
return { ok: true, editor: editor.createPageEditor, refreshView: editor.refreshMountedView, featureIds, code };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function prepareAuthority(inst, source) {
|
|
781
|
+
// The page's own code, with the editor it starts and the parts they share, is one
|
|
782
|
+
// download: it starts here, beside authority, and only a page that must rebuild
|
|
783
|
+
// its tag waits for it before the join. Nothing else on this path touches a vendor.
|
|
784
|
+
const codePromise = pageCodeReady();
|
|
785
|
+
try { meta = await hostMeta(); } catch (error) { meta = null; }
|
|
786
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
787
|
+
if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
|
|
788
|
+
showBlocked(inst, BLOCKED.code, BLOCKED.message);
|
|
789
|
+
return { ok: false, reason: "missing-capability" };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// A host that keeps no page data keeps its files untagged, and every one of them
|
|
793
|
+
// is merged by the boot instead of being read as a replacement.
|
|
794
|
+
inst.mergeTagless = !meta.extensions.includes("live-data");
|
|
795
|
+
// The one declaration that lets a host answer a seed-only body with nothing-to-write.
|
|
796
|
+
setSaveHeader("Page-Write", "v1");
|
|
797
|
+
// A seeded page is the one boot whose join needs the page code, which rebuilds
|
|
798
|
+
// the seed the article states.
|
|
799
|
+
let read = null;
|
|
800
|
+
let code = null;
|
|
801
|
+
if (source.seeded) {
|
|
802
|
+
read = await source.ready;
|
|
803
|
+
if (!read.ok) return read;
|
|
804
|
+
code = await codePromise;
|
|
805
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
806
|
+
}
|
|
807
|
+
// A tagless document has no page store for a room to hold, so its own is staged:
|
|
808
|
+
// the seed in memory and no write until a person edits. A tagged document opens
|
|
809
|
+
// the state the file already has.
|
|
810
|
+
let handle;
|
|
811
|
+
// Whether this session's own value is the store's: only a store the application
|
|
812
|
+
// opened itself keeps a page of its own.
|
|
813
|
+
let ownsStore = true;
|
|
814
|
+
try {
|
|
815
|
+
if (source.seeded) {
|
|
816
|
+
const staged = stageStore("page", {
|
|
817
|
+
value: source.seedValue,
|
|
818
|
+
represented: options.represented ?? { bytes: source.article.outerHTML, etag: representedEtag() ?? lastSeenEtag() },
|
|
819
|
+
});
|
|
820
|
+
inst.staged = staged;
|
|
821
|
+
handle = staged.handle;
|
|
365
822
|
} else {
|
|
366
|
-
|
|
823
|
+
const opened = storeOf("page") === null;
|
|
824
|
+
ownsStore = opened;
|
|
825
|
+
handle = window.clay.live("page");
|
|
826
|
+
if (opened) inst.pageStore = storeOf("page");
|
|
367
827
|
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (!
|
|
394
|
-
|
|
395
|
-
|
|
828
|
+
} catch (error) {
|
|
829
|
+
console.warn("clayjs: page plugin could not open the page store", error);
|
|
830
|
+
return { ok: false, reason: "missing-page-store" };
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
await handle.ready;
|
|
834
|
+
} catch (error) {
|
|
835
|
+
console.warn("clayjs: page plugin could not join the page room", error);
|
|
836
|
+
return { ok: false, reason: "admission-failed" };
|
|
837
|
+
}
|
|
838
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
839
|
+
// A tag the page's own code cannot decode is the boot's reason to stop, and both
|
|
840
|
+
// branches report the same one, so whichever settles first does not change it.
|
|
841
|
+
read ??= await source.ready;
|
|
842
|
+
if (!read.ok) return read;
|
|
843
|
+
// A store the application opened itself keeps the page it was opened from, and the
|
|
844
|
+
// file the reader holds is the page this session edits: the store is brought to that
|
|
845
|
+
// file before anything mounts on it, and a store that cannot take it refuses the
|
|
846
|
+
// entry rather than mounting a page the file does not state. A store that has joined
|
|
847
|
+
// a room is the exception: the room is newer than any file, and its value stands.
|
|
848
|
+
const inRoom = storeOf("page")?.handle?.status?.mode === "fast";
|
|
849
|
+
if (!ownsStore && !inRoom && !mergeRepresentedPage(storeOf("page"), source.tags?.[0])) {
|
|
850
|
+
return { ok: false, reason: "stale-store" };
|
|
851
|
+
}
|
|
852
|
+
code ??= await codePromise;
|
|
853
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
854
|
+
const store = storeOf("page");
|
|
855
|
+
if (!store) return { ok: false, reason: "missing-page-store" };
|
|
856
|
+
inst.store = store;
|
|
396
857
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
858
|
+
let flushFailed = false;
|
|
859
|
+
let registered = null;
|
|
860
|
+
const trackFlush = async (call) => {
|
|
861
|
+
const result = await call();
|
|
862
|
+
flushFailed = result?.ok === false;
|
|
863
|
+
registered?.changed();
|
|
864
|
+
return result;
|
|
865
|
+
};
|
|
866
|
+
identity = authorIdentity();
|
|
867
|
+
// The journal is installed before the editor is enabled, and whether or not this tab
|
|
868
|
+
// can reach the room: a refused offer, a lost room and an oversize update all leave
|
|
869
|
+
// the work in a document that a crash must still be able to recover.
|
|
870
|
+
const journal = await openJournal(meta, store);
|
|
871
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
872
|
+
binding = code.createYPageBinding({
|
|
873
|
+
store,
|
|
874
|
+
onStopped: (reason) => {
|
|
875
|
+
inst.tabValue = code.yValueOf(binding.ydoc);
|
|
876
|
+
if (reason === "lineage") {
|
|
877
|
+
fastLane()?.abandon?.("page");
|
|
878
|
+
showReplaced(inst);
|
|
879
|
+
} else {
|
|
880
|
+
showTooLarge(inst);
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
authorToken: identity.token,
|
|
884
|
+
ownedTokens: identity.owned,
|
|
885
|
+
checkpointWithin,
|
|
886
|
+
requestCheckpoint,
|
|
887
|
+
journal,
|
|
888
|
+
registerUnsavedState: (def) => {
|
|
889
|
+
registered = registerUnsavedState({
|
|
890
|
+
id: def.id,
|
|
891
|
+
isPending: () => flushFailed || def.isPending(),
|
|
892
|
+
flush: () => trackFlush(() => def.flush()),
|
|
893
|
+
});
|
|
894
|
+
return registered;
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
inst.binding = binding;
|
|
898
|
+
if (journal) installCheckpointProof(inst);
|
|
899
|
+
inst.flush = (options) => trackFlush(() => binding.flush(options));
|
|
900
|
+
listenForSaves(inst);
|
|
901
|
+
return { ok: true, store, handle };
|
|
408
902
|
}
|
|
409
|
-
|
|
410
|
-
|
|
903
|
+
|
|
904
|
+
// Other people's carets arrive on their own: the decorations load only where a
|
|
905
|
+
// host offers page awareness, after the editor is mounted, so neither a reader nor
|
|
906
|
+
// an editor on a host without presence pays for them.
|
|
907
|
+
function installAwareness(inst, view) {
|
|
908
|
+
import("../page/awareness.js").then(async (mod) => {
|
|
909
|
+
if (inst.view !== view) return;
|
|
910
|
+
const handle = await mod.installPageAwareness({ binding: inst.binding, view });
|
|
911
|
+
if (!handle) return;
|
|
912
|
+
if (inst.view !== view || view.isDestroyed) { handle.dispose(); return; }
|
|
913
|
+
inst.awareness = handle;
|
|
914
|
+
}).catch((error) => console.error("clayjs: page plugin remote carets failed to load", error));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function mountAndAttach(inst, source, loaded) {
|
|
918
|
+
const article = source.element;
|
|
919
|
+
// The reader that showed this article gives its claim up here and keeps its queue,
|
|
920
|
+
// so a preparation that fails after this point hands the article back.
|
|
921
|
+
inst.context?.reader?.yieldArticle?.();
|
|
922
|
+
// The claim comes before this install records the article as its own to
|
|
923
|
+
// restore: a claim another runtime holds is refused, and neither the owner's
|
|
924
|
+
// article nor this install's restoration ownership may change.
|
|
411
925
|
try {
|
|
412
|
-
|
|
413
|
-
await toolsStore.ready;
|
|
926
|
+
inst.releaseArticle = claim(article, "editor");
|
|
414
927
|
} catch (error) {
|
|
415
|
-
console.
|
|
928
|
+
console.error("clayjs: page plugin could not claim the article", error);
|
|
929
|
+
return { ok: false, reason: "article-owned" };
|
|
416
930
|
}
|
|
931
|
+
inst.source = source;
|
|
932
|
+
inst.article = article;
|
|
933
|
+
inst.bootHtml = source.article.innerHTML;
|
|
934
|
+
inst.bootRev = source.article.getAttribute("data-rev");
|
|
935
|
+
inst.savedRev = inst.bootRev;
|
|
936
|
+
listen(inst, "clay:sync-applied", (event) => {
|
|
937
|
+
if (inst.replaced) return;
|
|
938
|
+
if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
|
|
939
|
+
showReplaced(inst);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// A page with blocks whose view parts are still coming keeps its saved article on
|
|
943
|
+
// screen, and the editor out of sight, until they are here. One with none of those
|
|
944
|
+
// blocks has nothing to wait for, so its saved article goes now. Either way it is
|
|
945
|
+
// this capture, taken before anything changes, that puts the article back.
|
|
946
|
+
savedContent = [...article.childNodes].filter((node) => node !== inst.statusNote);
|
|
947
|
+
// The shell is built out of the page, in an element of the same document, so a
|
|
948
|
+
// creation that fails never leaves half an editor over the saved article.
|
|
949
|
+
const staging = article.ownerDocument.createElement("div");
|
|
950
|
+
let view = null;
|
|
951
|
+
try {
|
|
952
|
+
view = loaded.editor(staging, {
|
|
953
|
+
doc: binding.doc(),
|
|
954
|
+
dispatch: binding.dispatch,
|
|
955
|
+
history: false,
|
|
956
|
+
undo: binding.undo,
|
|
957
|
+
redo: binding.redo,
|
|
958
|
+
board: binding.board,
|
|
959
|
+
editable: false,
|
|
960
|
+
features,
|
|
961
|
+
onAiTool: aiRequested() ? (_view, remove) => { openAi({ remove }); } : null,
|
|
962
|
+
reviseTool: aiRequested() ? (toolId, rev) => { openAi({ target: { toolId, rev } }); } : null,
|
|
963
|
+
formatGate: inst.loadFormatGate,
|
|
964
|
+
});
|
|
965
|
+
} catch (error) {
|
|
966
|
+
console.error("clayjs: page plugin could not mount the editor", error);
|
|
967
|
+
staging.replaceChildren();
|
|
968
|
+
inst.releaseArticle?.();
|
|
969
|
+
inst.releaseArticle = null;
|
|
970
|
+
inst.article = null;
|
|
971
|
+
return { ok: false, reason: "mount-failed" };
|
|
972
|
+
}
|
|
973
|
+
editorView = view;
|
|
974
|
+
inst.view = view;
|
|
975
|
+
const shell = view.dom.closest(".page-shell");
|
|
976
|
+
// A shell still waiting for its boot takes no input of any kind: a control inside
|
|
977
|
+
// a non-editable editor would otherwise write before the boot says ready. It is
|
|
978
|
+
// cleared at the one place input is enabled.
|
|
979
|
+
if (shell) shell.inert = true;
|
|
980
|
+
if (loaded.featureIds.length && shell) shell.style.visibility = "hidden";
|
|
981
|
+
// One synchronous move: the finished shell replaces the saved surface as a
|
|
982
|
+
// single change, and the article element itself stays where it is.
|
|
983
|
+
if (!loaded.featureIds.length) for (const node of savedContent) node.remove();
|
|
984
|
+
if (shell) article.appendChild(shell);
|
|
985
|
+
loaded.refreshView?.(view);
|
|
986
|
+
binding.attach(view);
|
|
987
|
+
installAwareness(inst, view);
|
|
988
|
+
editorReadyResolve();
|
|
989
|
+
// The parts the saved article demands install onto the mounted view now, whether
|
|
990
|
+
// their fetch already landed or is still on the wire; the saved article stays and
|
|
991
|
+
// input stays off until they are in.
|
|
992
|
+
featuresReady = Promise.allSettled(loaded.featureIds.map((id) => features.ensure(id)));
|
|
993
|
+
scrollToHashBlock(view);
|
|
994
|
+
inst.recover = loaded.code.createRecoverList({
|
|
995
|
+
binding,
|
|
996
|
+
mount: view.dom.closest(".page-shell") ?? article,
|
|
997
|
+
ownTokens: [identity.token, ...identity.owned],
|
|
998
|
+
onRestored: (id) => loaded.code.focusBlock(view, id),
|
|
999
|
+
onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
|
|
1000
|
+
});
|
|
1001
|
+
inst.titleObserver = loaded.code.observeDocumentTitle(view);
|
|
1002
|
+
return { ok: true, view, featureIds: loaded.featureIds };
|
|
417
1003
|
}
|
|
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
1004
|
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
1005
|
+
// Whether the room's page state already carries the hand edit: merging the article's
|
|
1006
|
+
// changes into the room's blocks changes nothing. It is the comparison a checkpoint
|
|
1007
|
+
// makes before it decides the room holds an edit, run against the room's state
|
|
1008
|
+
// instead of the saved tag, so a room a peer changed after the adoption holds the
|
|
1009
|
+
// edit too.
|
|
1010
|
+
async function roomHoldsArticle(inst, found) {
|
|
1011
|
+
try {
|
|
1012
|
+
const code = await pageCodeReady();
|
|
1013
|
+
const { HyperMorph } = await mergeEngine();
|
|
1014
|
+
const blocks = code.blocksFromYValue(inst.store.authoritative);
|
|
1015
|
+
const current = code.readBack(blocks, document);
|
|
1016
|
+
const merged = code.mergePageModels(found.baseItems, current, found.parsed, {
|
|
1017
|
+
doc: document,
|
|
1018
|
+
merge3: HyperMorph.merge3,
|
|
1019
|
+
mergeJson: HyperMorph.mergeJson,
|
|
1020
|
+
});
|
|
1021
|
+
return code.canon(merged.items) === code.canon(current);
|
|
1022
|
+
} catch {
|
|
1023
|
+
return false;
|
|
469
1024
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// A tab that is not the room's writer does not adopt: the elected writer lands the
|
|
1028
|
+
// hand edit, and it reaches this tab as a page update through the room, which the
|
|
1029
|
+
// store hands to this tab's binding. A room that already holds the edit has nothing
|
|
1030
|
+
// to wait for. Otherwise the saved article stays up and takes no typing until the
|
|
1031
|
+
// writer's adoption arrives, and the wait is bounded like adoption itself.
|
|
1032
|
+
async function awaitWriterAdoption(inst, found) {
|
|
1033
|
+
const code = await pageCodeReady();
|
|
1034
|
+
if (await roomHoldsArticle(inst, found)) return { ok: true, outcome: "writer-adopts" };
|
|
1035
|
+
return new Promise((resolve) => {
|
|
1036
|
+
let off = null;
|
|
1037
|
+
let timer = null;
|
|
1038
|
+
let done = false;
|
|
1039
|
+
const finish = (result) => {
|
|
1040
|
+
if (done) return;
|
|
1041
|
+
done = true;
|
|
1042
|
+
if (timer) clearTimeout(timer);
|
|
1043
|
+
timer = null;
|
|
1044
|
+
off?.();
|
|
1045
|
+
off = null;
|
|
1046
|
+
resolve(result);
|
|
1047
|
+
};
|
|
1048
|
+
addDisposer(inst, () => finish({ ok: false, cancelled: true }));
|
|
1049
|
+
off = inst.store.onRebase((event) => {
|
|
1050
|
+
if (event.source === "remote") finish({ ok: true, outcome: "writer-adopts" });
|
|
495
1051
|
});
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1052
|
+
timer = setTimeout(() => {
|
|
1053
|
+
console.warn("page: gave up waiting for the writer\u0027s adoption; the hand edit was not adopted");
|
|
1054
|
+
finish({ ok: false, reason: "adoption-timeout" });
|
|
1055
|
+
}, code.ADOPTION_TIMEOUT_MS);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function adoptSource(inst, source, mounted) {
|
|
1060
|
+
const code = await pageCodeReady();
|
|
1061
|
+
// A hand-edited article is adopted by the room's elected writer only. With a
|
|
1062
|
+
// lane but no writer yet, this tab claims the room (a redundant set of an
|
|
1063
|
+
// existing Yjs update: a valid nonempty frame that changes nothing) and then
|
|
1064
|
+
// waits for the election, so exactly one tab adopts.
|
|
1065
|
+
const found = code.classifyFile(source.article, source.tags);
|
|
1066
|
+
const needsAdoption = found.kind === "adopt";
|
|
1067
|
+
const awaitWriter = async () => {
|
|
1068
|
+
const lane = await pageLaneReady();
|
|
1069
|
+
if (!lane) return;
|
|
1070
|
+
if (!isCurrent(inst)) return;
|
|
1071
|
+
if (lane.status.writer === null) {
|
|
1072
|
+
const y = inst.store.value.y;
|
|
1073
|
+
const keys = Object.keys(y);
|
|
1074
|
+
const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
|
|
1075
|
+
const value = keys.length ? y[keys[0]] : code.yValueOf(binding.ydoc).y.seed;
|
|
1076
|
+
inst.store.handle.set(["y", key], value);
|
|
1077
|
+
// The session is not admitted until the commit, so its ops stay in the tab: the
|
|
1078
|
+
// claim alone is framed now, before adoption writes anything, or no election runs.
|
|
1079
|
+
lane.release("page");
|
|
1080
|
+
}
|
|
1081
|
+
// A lane that never reports a writer must not cost the hand edit: give up
|
|
1082
|
+
// before adoption's own bound, and adopt as the file-mode tab would.
|
|
1083
|
+
let timer;
|
|
1084
|
+
await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
|
|
1085
|
+
clearTimeout(timer);
|
|
1086
|
+
};
|
|
1087
|
+
const ready = needsAdoption ? awaitWriter() : Promise.resolve();
|
|
1088
|
+
|
|
1089
|
+
const adoption = code.installAdoption({
|
|
1090
|
+
view: mounted.view,
|
|
1091
|
+
source,
|
|
1092
|
+
ready,
|
|
1093
|
+
isAdopter: () => {
|
|
1094
|
+
const lane = fastLane();
|
|
1095
|
+
return !lane || lane.status.writer === null ? true : lane.isWriter();
|
|
1096
|
+
},
|
|
1097
|
+
requestCheckpoint: () => checkpointWithin(0),
|
|
1098
|
+
snapshot: () => captureForSaveAndComparison().forSave,
|
|
1099
|
+
mergeTagless: inst.mergeTagless,
|
|
1100
|
+
writerReady: awaitWriter,
|
|
1101
|
+
adopted: (rev) => fastLane()?.adopted?.("page", rev, inst.store.mark),
|
|
1102
|
+
notice: (counts) => {
|
|
1103
|
+
if (!counts.copy) return;
|
|
1104
|
+
toastPersistent(OVERWRITTEN_TEXT, "warning", {
|
|
1105
|
+
actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
|
|
1106
|
+
});
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
inst.adoption = adoption;
|
|
1110
|
+
|
|
1111
|
+
let outcome = await adoption.idle();
|
|
1112
|
+
if (!outcome) {
|
|
1113
|
+
await replayJournal(inst);
|
|
1114
|
+
return { ok: true, outcome: "unchanged" };
|
|
1115
|
+
}
|
|
1116
|
+
// Another tab is the room's writer, so this tab's adoption is the one that tab
|
|
1117
|
+
// lands: the wait ends when it reaches this tab, or as the timeout a refusal is.
|
|
1118
|
+
if (outcome.outcome === "writer-adopts") outcome = await awaitWriterAdoption(inst, found);
|
|
1119
|
+
// Adoption refused, timed out or failed: the room's state is the page's, not the
|
|
1120
|
+
// view this tab built over the saved article, so the editor goes and the article
|
|
1121
|
+
// comes back from what the room accepted. A disposed install restores nothing:
|
|
1122
|
+
// dispose already did.
|
|
1123
|
+
if (!outcome.ok && isCurrent(inst)) abandonEditor(inst);
|
|
1124
|
+
else if (isCurrent(inst)) await replayJournal(inst);
|
|
1125
|
+
return outcome;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Everything input waits for, checked without turning anything on: the parts the
|
|
1129
|
+
// saved article demands, any part the document gained while the page booted, and this
|
|
1130
|
+
// installation's own view still in the article it claimed.
|
|
1131
|
+
async function prepareInput(inst, mounted) {
|
|
1132
|
+
// A replacement that landed while the page was still booting keeps the editor
|
|
1133
|
+
// read-only: the notice is already up, and this tab must not edit the old article.
|
|
1134
|
+
if (inst.replaced) return { ok: false, reason: "page-replaced" };
|
|
1135
|
+
await featuresReady;
|
|
1136
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
1137
|
+
// The gate: this installation's own view, still in the article it claimed, over
|
|
1138
|
+
// the binding the room accepted. Anything else is not something to type into.
|
|
1139
|
+
const shell = mounted.view.dom.closest(".page-shell");
|
|
1140
|
+
if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
|
|
1141
|
+
return { ok: false, reason: "view-detached" };
|
|
1142
|
+
}
|
|
1143
|
+
// The document the view holds can ask for a part the saved article never did: a
|
|
1144
|
+
// peer put a table in the room, or a hand edit spliced one in during adoption.
|
|
1145
|
+
// Those are the last parts input waits for, before the article gives way.
|
|
1146
|
+
const booted = new Set(mounted.featureIds ?? []);
|
|
1147
|
+
const late = docFeatureIds(mounted.view.state.doc).filter((id) => !booted.has(id));
|
|
1148
|
+
if (late.length) {
|
|
1149
|
+
await Promise.allSettled(late.map((id) => features.ensure(id)));
|
|
1150
|
+
if (!isCurrent(inst)) return { ok: false, cancelled: true };
|
|
1151
|
+
}
|
|
1152
|
+
return { ok: true };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// The one place the saved article gives way to the editor, and the only place input
|
|
1156
|
+
// opens. It changes activation only, and only after preparation already passed, so
|
|
1157
|
+
// the commit that enters edit cannot fail here.
|
|
1158
|
+
function openInput(inst, mounted) {
|
|
1159
|
+
const shell = mounted.view.dom.closest(".page-shell");
|
|
1160
|
+
const showEditor = () => {
|
|
1161
|
+
for (const node of savedContent) node.remove();
|
|
1162
|
+
if (shell) shell.style.visibility = "";
|
|
1163
|
+
};
|
|
1164
|
+
// A replacement that landed during the feature wait keeps the editor read-only,
|
|
1165
|
+
// exactly as one that landed before it does.
|
|
1166
|
+
if (inst.replaced) {
|
|
1167
|
+
showEditor();
|
|
1168
|
+
stopEditing(inst);
|
|
1169
|
+
return { ok: false, reason: "page-replaced" };
|
|
1170
|
+
}
|
|
1171
|
+
if (inst.inputOpen) return { ok: true };
|
|
1172
|
+
if (!ownsView(inst, mounted) || !shell || !shell.isConnected || mounted.view.isDestroyed) {
|
|
1173
|
+
return { ok: false, reason: "view-detached" };
|
|
1174
|
+
}
|
|
1175
|
+
showEditor();
|
|
1176
|
+
shell.inert = false;
|
|
1177
|
+
setEditorActive?.(mounted.view, true);
|
|
1178
|
+
inst.inputOpen = true;
|
|
1179
|
+
clearLoadingStatus(inst);
|
|
1180
|
+
return { ok: true };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// A refused or unfinished exit closes admission again without touching a buffer, so
|
|
1184
|
+
// a `resume` puts the very same session back.
|
|
1185
|
+
function closeInput(inst) {
|
|
1186
|
+
if (!inst.inputOpen) return false;
|
|
1187
|
+
setEditorActive?.(inst.view, false);
|
|
1188
|
+
return true;
|
|
507
1189
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// was opened and left alone does not read as unsaved.
|
|
514
|
-
if (initialSave) {
|
|
515
|
-
const { forComparison, forDirty } = captureForComparisonAndDirty();
|
|
516
|
-
setLastSavedBaselines(forComparison, forDirty);
|
|
1190
|
+
|
|
1191
|
+
function resumeInput(inst) {
|
|
1192
|
+
if (!inst.inputOpen) return false;
|
|
1193
|
+
setEditorActive?.(inst.view, true);
|
|
1194
|
+
return true;
|
|
517
1195
|
}
|
|
1196
|
+
|
|
1197
|
+
// The legacy boot: preparation and the commit are one, because there is no
|
|
1198
|
+
// coordinator to wait for.
|
|
1199
|
+
async function enableInput(inst, mounted) {
|
|
1200
|
+
const prepared = await prepareInput(inst, mounted);
|
|
1201
|
+
if (!prepared.ok) return prepared;
|
|
1202
|
+
return openInput(inst, mounted);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// A boot that ends without an editor gives up what its branches acquired: the room's
|
|
1206
|
+
// subscription, its save participant and its listeners. A boot that mounted a view
|
|
1207
|
+
// releases its own through the paths above and keeps the editor on screen.
|
|
1208
|
+
const release = (inst) => {
|
|
1209
|
+
if (inst.view) return;
|
|
1210
|
+
abandonEditor(inst);
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const steps = { readSource, loadEditorAssets, prepareAuthority, mountAndAttach, adoptSource, prepareInput, openInput, closeInput, resumeInput, enableInput, release };
|
|
1214
|
+
inst.steps = steps;
|
|
1215
|
+
return steps;
|
|
518
1216
|
}
|
|
519
1217
|
|
|
520
|
-
function
|
|
1218
|
+
async function run(inst, options = {}, { activate = true } = {}) {
|
|
1219
|
+
return runBoot(inst, createSteps(inst, options), { activate });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// One installation, and the boot it runs. A managed session is the same installation
|
|
1223
|
+
// with its commit left to the coordinator: `activate: false` stops the boot where the
|
|
1224
|
+
// editor is staged and still takes no input.
|
|
1225
|
+
function begin(options = {}, { activate = true, context = null } = {}) {
|
|
521
1226
|
dispose();
|
|
1227
|
+
ended = null;
|
|
522
1228
|
const gen = ++generation;
|
|
523
|
-
const inst = {
|
|
1229
|
+
const inst = {
|
|
1230
|
+
...createInstall({
|
|
1231
|
+
gen,
|
|
1232
|
+
isCurrent: () => gen === generation && (context?.isCurrent?.() ?? true),
|
|
1233
|
+
onPhase: (result) => {
|
|
1234
|
+
// A settled boot says so by taking the waiting line away, whatever it settled
|
|
1235
|
+
// as: there is nothing left to wait for.
|
|
1236
|
+
if (result.phase === "ready" || result.phase === "blocked" || result.phase === "error") clearLoadingStatus(inst);
|
|
1237
|
+
options.onPhase?.(result);
|
|
1238
|
+
},
|
|
1239
|
+
}),
|
|
1240
|
+
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, loadFormatGate: null, listeners: [], retryTimer: null, retryShown: false, statusTimer: null, statusNote: null, notices: null, editorReadyResolve: null, context, inputOpen: false, sessionHeld: false, autosaveSuspended: false, staleRetry: false,
|
|
1241
|
+
};
|
|
524
1242
|
active = inst;
|
|
525
|
-
|
|
1243
|
+
const boot = run(inst, options, { activate }).catch((error) => {
|
|
526
1244
|
console.error("clayjs: page plugin failed to install", error);
|
|
1245
|
+
ended = finishError(inst, "boot-failed");
|
|
527
1246
|
if (active === inst) dispose();
|
|
1247
|
+
return ended;
|
|
1248
|
+
});
|
|
1249
|
+
return { inst, boot };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function install(options = {}) {
|
|
1253
|
+
return begin(options).boot;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// What a session's own end gives back, whether or not the coordinator still calls the
|
|
1257
|
+
// page: its view, its listeners, its binding and its staged store go, and the article
|
|
1258
|
+
// comes back from the page the host accepted rather than from the editor's own DOM.
|
|
1259
|
+
function endSession(inst) {
|
|
1260
|
+
if (active === inst) {
|
|
1261
|
+
active = null;
|
|
1262
|
+
generation += 1;
|
|
1263
|
+
}
|
|
1264
|
+
setSaveHeader("Page-Write", null);
|
|
1265
|
+
disposeInstall(inst);
|
|
1266
|
+
if (inst.retryTimer) {
|
|
1267
|
+
clearTimeout(inst.retryTimer);
|
|
1268
|
+
inst.retryTimer = null;
|
|
1269
|
+
}
|
|
1270
|
+
inst.titleObserver?.dispose();
|
|
1271
|
+
inst.titleObserver = null;
|
|
1272
|
+
clearLoadingStatus(inst);
|
|
1273
|
+
if (inst.view && !inst.view.isDestroyed) {
|
|
1274
|
+
try { inst.view.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
|
|
1275
|
+
}
|
|
1276
|
+
inst.view = null;
|
|
1277
|
+
releaseMounted(inst);
|
|
1278
|
+
restoreArticle(inst);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// The save hold a managed session takes between its preparation and its commit: given
|
|
1282
|
+
// back once, by the enable that commits it or by the preparation that fails.
|
|
1283
|
+
function releaseSessionHold(inst) {
|
|
1284
|
+
if (!inst.sessionHeld) return;
|
|
1285
|
+
inst.sessionHeld = false;
|
|
1286
|
+
releaseAllSaves({ replay: false, reason: SESSION_HOLD });
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// What both roads hold between the commit that enters edit and the one that leaves it:
|
|
1290
|
+
// a refused exit is `quiesce` then `resume`, and the committed exit is `dispose`. Only a
|
|
1291
|
+
// session preparation holds takes a save hold to give back; an adopted one took none.
|
|
1292
|
+
function sessionLease(inst, { held = true } = {}) {
|
|
1293
|
+
return {
|
|
1294
|
+
// Synchronous after preparation: it opens admission, input and the lane's writes,
|
|
1295
|
+
// and nothing it does can fail. An adopted session is already open.
|
|
1296
|
+
enable() {
|
|
1297
|
+
if (!held) return;
|
|
1298
|
+
releaseSessionHold(inst);
|
|
1299
|
+
activateInput(inst);
|
|
1300
|
+
},
|
|
1301
|
+
quiesce() {
|
|
1302
|
+
// A composition in flight is not this session's to cut: the exit is refused, and
|
|
1303
|
+
// the text stays where the person is typing it.
|
|
1304
|
+
if (inst.view?.composing) {
|
|
1305
|
+
throw Object.assign(new Error("clayjs: a composition is still in progress"), { code: "composition" });
|
|
1306
|
+
}
|
|
1307
|
+
inst.steps?.closeInput?.(inst);
|
|
1308
|
+
// Scheduled autosaves stop here; the buffers, the selection and the exit's own
|
|
1309
|
+
// flush stay exactly as they were.
|
|
1310
|
+
if (!inst.autosaveSuspended) {
|
|
1311
|
+
inst.autosaveSuspended = true;
|
|
1312
|
+
suspendAutosave();
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
resume() {
|
|
1316
|
+
releaseSessionHold(inst);
|
|
1317
|
+
if (inst.autosaveSuspended) {
|
|
1318
|
+
inst.autosaveSuspended = false;
|
|
1319
|
+
resumeAutosave();
|
|
1320
|
+
}
|
|
1321
|
+
inst.steps?.resumeInput?.(inst);
|
|
1322
|
+
},
|
|
1323
|
+
dispose() {
|
|
1324
|
+
releaseSessionHold(inst);
|
|
1325
|
+
if (inst.autosaveSuspended) {
|
|
1326
|
+
inst.autosaveSuspended = false;
|
|
1327
|
+
resumeAutosave();
|
|
1328
|
+
}
|
|
1329
|
+
endSession(inst);
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// What the coordinator holds between the commit that enters edit and the one that
|
|
1335
|
+
// leaves it: preparation is `ready`, the commit is `enable`, a refused exit is
|
|
1336
|
+
// `quiesce` then `resume`, and the committed exit is `dispose`.
|
|
1337
|
+
export function preparePageSession(context = null, options = {}) {
|
|
1338
|
+
holdAllSaves(SESSION_HOLD);
|
|
1339
|
+
const { inst, boot } = begin(options, { activate: false, context });
|
|
1340
|
+
inst.sessionHeld = true;
|
|
1341
|
+
const lease = sessionLease(inst, { held: true });
|
|
1342
|
+
// The facade reads a session as the boot that is staging it, so it settles at the
|
|
1343
|
+
// staging or at the blocked outcome, and never rejects.
|
|
1344
|
+
page.ready = boot.then(() => page.status);
|
|
1345
|
+
const ready = boot.then((result) => {
|
|
1346
|
+
if (!result?.deferred) {
|
|
1347
|
+
throw Object.assign(new Error("clayjs: the page session could not prepare"), { code: result?.reason ?? "load-failed" });
|
|
1348
|
+
}
|
|
1349
|
+
return undefined;
|
|
1350
|
+
});
|
|
1351
|
+
// A preparation the coordinator never holds, and never disposes either, must not
|
|
1352
|
+
// keep the page's saves closed: it gives its hold back as it fails, and a reader that
|
|
1353
|
+
// yielded the article takes it back before the failure reaches the coordinator.
|
|
1354
|
+
ready.catch(() => {
|
|
1355
|
+
releaseSessionHold(inst);
|
|
1356
|
+
try { context?.reader?.reclaimArticle?.(); } catch (error) { console.error("clayjs: the reader could not take the article back", error); }
|
|
528
1357
|
});
|
|
1358
|
+
return { ready, ...lease };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* The page as a mode participant: the editor the commit that enters edit opens, over
|
|
1363
|
+
* the page the host already stores, and the one the exit takes back. `prepare` stages
|
|
1364
|
+
* the article, the room and the editor without taking input or writing; the commit
|
|
1365
|
+
* enables the same session.
|
|
1366
|
+
*/
|
|
1367
|
+
export function pageParticipant(options = {}) {
|
|
1368
|
+
return {
|
|
1369
|
+
id: "clay:page",
|
|
1370
|
+
// After live (200) and upload (340): the page builds its stores over the admission
|
|
1371
|
+
// those two already opened.
|
|
1372
|
+
order: 400,
|
|
1373
|
+
prepare(context = {}) {
|
|
1374
|
+
// An adoption is the session this tab's edit boot started: the exit takes that one
|
|
1375
|
+
// back, and the entry starts none.
|
|
1376
|
+
if (context.initial === true) {
|
|
1377
|
+
if (!active) return { enable() {}, quiesce() {}, resume() {}, dispose() {} };
|
|
1378
|
+
return sessionLease(active, { held: false });
|
|
1379
|
+
}
|
|
1380
|
+
return preparePageSession(context, options);
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
529
1383
|
}
|
|
530
1384
|
|
|
1385
|
+
export { pageParticipant as modeParticipant };
|
|
1386
|
+
|
|
531
1387
|
export const page = {
|
|
532
1388
|
ready: null,
|
|
533
1389
|
install,
|
|
1390
|
+
get status() {
|
|
1391
|
+
return active?.result ?? ended;
|
|
1392
|
+
},
|
|
534
1393
|
flush(options = {}) {
|
|
535
1394
|
const inst = active;
|
|
536
1395
|
if (!inst || !inst.flush) return Promise.resolve({ ok: false, code: "disposed" });
|
|
@@ -542,6 +1401,8 @@ export const page = {
|
|
|
542
1401
|
},
|
|
543
1402
|
};
|
|
544
1403
|
|
|
545
|
-
|
|
1404
|
+
// A live entry imports this module while the committed mode is still view: the install
|
|
1405
|
+
// belongs to the explicit edit boot alone, and a managed session starts its own.
|
|
1406
|
+
page.ready = startsAtImport() ? install() : null;
|
|
546
1407
|
|
|
547
1408
|
export default page;
|