@panphora/clayjs 1.15.2 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +83090 -58706
- 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-NWPPXOCZ.js +1 -0
- package/dist/m/attrs/save-freeze-FUP4Q4X3.js +1 -0
- package/dist/m/c-2AZDZWCK.js +1 -0
- package/dist/m/c-2JL6E75N.js +1 -0
- package/dist/m/c-2M73252O.js +1 -0
- package/dist/m/c-2SPVQSHC.js +1 -0
- package/dist/m/c-33HUYEDI.js +1 -0
- package/dist/m/c-33JJ6CBX.js +2 -0
- package/dist/m/c-35NBGMZB.js +1 -0
- package/dist/m/c-3GJ4SDE2.js +1 -0
- package/dist/m/c-3IQRECYS.js +1 -0
- package/dist/m/c-3OWDMNJR.js +2 -0
- package/dist/m/c-46XYM55L.js +1 -0
- package/dist/m/c-4B6H4G5V.js +1 -0
- package/dist/m/c-4DRMS4QG.js +9 -0
- package/dist/m/c-4FFHZM32.js +1 -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-4YSCRICO.js +1 -0
- package/dist/m/c-54RCDCBU.js +1 -0
- package/dist/m/c-5M4YEKCX.js +4 -0
- package/dist/m/c-5R4PTRG2.js +1 -0
- package/dist/m/c-5V3MOVJT.js +1 -0
- package/dist/m/c-5VYOWDTI.js +1 -0
- package/dist/m/c-5Y6TNX5W.js +34 -0
- package/dist/m/c-5YIHJ3TJ.js +1 -0
- package/dist/m/c-66O32MV3.js +28 -0
- package/dist/m/c-6HMB24EV.js +1 -0
- package/dist/m/c-7DJDKPGL.js +1 -0
- package/dist/m/c-7O6AFR65.js +1 -0
- package/dist/m/c-7SWMYKAI.js +1 -0
- package/dist/m/c-7VGZ7RU5.js +1 -0
- package/dist/m/c-A7LS5UZW.js +1 -0
- package/dist/m/c-ANQTJ3B5.js +2 -0
- package/dist/m/c-AQLFEATB.js +1 -0
- package/dist/m/c-AYVOREBE.js +1 -0
- package/dist/m/c-B2ZXPOS3.js +3 -0
- package/dist/m/c-BBVGTQG7.js +1 -0
- package/dist/m/c-BMIZKMPV.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-CJZSLMVQ.js +1 -0
- package/dist/m/c-CYWLPPUS.js +1 -0
- package/dist/m/c-CZUSOB7V.js +1 -0
- package/dist/m/c-DBE6ORSI.js +1 -0
- package/dist/m/c-DCFSEJZG.js +1 -0
- package/dist/m/c-DNZ5IFM6.js +1 -0
- package/dist/m/c-EDHUR4MX.js +1 -0
- package/dist/m/c-EJTGYSLN.js +1 -0
- package/dist/m/c-EY6UOM54.js +1 -0
- package/dist/m/c-F3CKJTJS.js +1 -0
- package/dist/m/c-F7Y6FLPY.js +1 -0
- package/dist/m/c-FA4DZVTC.js +1 -0
- package/dist/m/c-FILLTJA7.js +1 -0
- package/dist/m/c-G2BUZITR.js +1 -0
- package/dist/m/c-GB3JTV6E.js +1 -0
- package/dist/m/c-GBGWXTX4.js +1 -0
- package/dist/m/c-GHBCCVG4.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-H4LG442Q.js +1 -0
- package/dist/m/c-HA6INT6J.js +1 -0
- package/dist/m/c-HPURYVZS.js +2 -0
- package/dist/m/c-HRDCD3WN.js +67 -0
- package/dist/m/c-HT33EQRN.js +1 -0
- package/dist/m/c-HVYXIS72.js +1 -0
- package/dist/m/c-IB52R237.js +1 -0
- package/dist/m/c-IJUK2GPW.js +1 -0
- package/dist/m/c-IKWNE4R4.js +1 -0
- package/dist/m/c-IU3IYEU6.js +1 -0
- package/dist/m/c-IZ2EBRAJ.js +1 -0
- package/dist/m/c-J7CA54IP.js +1 -0
- package/dist/m/c-JAX2667S.js +6 -0
- package/dist/m/c-KI7M4AY3.js +1 -0
- package/dist/m/c-KIPQJX4B.js +1 -0
- package/dist/m/c-KKWLDOPM.js +1 -0
- package/dist/m/c-KR4UW4KW.js +1 -0
- package/dist/m/c-KY3VJGUA.js +8 -0
- package/dist/m/c-L4WQSC27.js +1 -0
- package/dist/m/c-LFPHCXY3.js +1 -0
- package/dist/m/c-LR4CGEPT.js +91 -0
- package/dist/m/c-LRPUO2MX.js +1 -0
- package/dist/m/c-LTGMXXKO.js +1 -0
- package/dist/m/c-LXENEBV7.js +1 -0
- package/dist/m/c-LZJPNR2W.js +1 -0
- package/dist/m/c-MMUIUUZG.js +2 -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-NMJRXKPC.js +8 -0
- package/dist/m/c-NRPJEVJS.js +1 -0
- package/dist/m/c-NUHP2XDB.js +1 -0
- package/dist/m/c-NZMNXVF6.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-OYTFP5QN.js +1 -0
- package/dist/m/c-P7KNDG34.js +1 -0
- package/dist/m/c-PPOSCAQD.js +1 -0
- package/dist/m/c-PQ5LK6QE.js +993 -0
- package/dist/m/c-PZ4XEPUP.js +2 -0
- package/dist/m/c-QHIWXHRO.js +1 -0
- package/dist/m/c-QHWJ5ZIJ.js +1 -0
- package/dist/m/c-QIX5BJYN.js +1 -0
- package/dist/m/c-QNKREOL6.js +1 -0
- package/dist/m/c-QQHEX6HA.js +1 -0
- package/dist/m/c-R7RMANJI.js +9 -0
- package/dist/m/c-RI4NT75Y.js +1 -0
- package/dist/m/c-RM7J7BV2.js +1 -0
- package/dist/m/c-RUJKSUQW.js +1 -0
- package/dist/m/c-RVMWGXEJ.js +1 -0
- package/dist/m/c-S7H2DRCB.js +1 -0
- package/dist/m/c-S7LK4KUE.js +1 -0
- package/dist/m/c-SGCCC4FW.js +25 -0
- package/dist/m/c-SGIA4IEQ.js +188 -0
- package/dist/m/c-SWRJFCNN.js +1 -0
- package/dist/m/c-T4VO4OCB.js +1 -0
- package/dist/m/c-TCCA5BZN.js +2 -0
- package/dist/m/c-TQEMVJCS.js +1 -0
- package/dist/m/c-TQZ2JYHD.js +1 -0
- package/dist/m/c-TR6RMDQ7.js +3 -0
- package/dist/m/c-TRS6TM6O.js +1 -0
- package/dist/m/c-UGHH4MB3.js +1 -0
- package/dist/m/c-UKHTLWUW.js +1 -0
- package/dist/m/c-UL2YUY52.js +1 -0
- package/dist/m/c-UNN7LWCT.js +1 -0
- package/dist/m/c-UXBMRDZB.js +235 -0
- package/dist/m/c-V7PZZRG7.js +1 -0
- package/dist/m/c-VAK7SG37.js +1 -0
- package/dist/m/c-VDSS4K3Z.js +1 -0
- package/dist/m/c-VFX45IZQ.js +1 -0
- package/dist/m/c-VHNSBD2M.js +1 -0
- package/dist/m/c-VKEDWYDF.js +1 -0
- package/dist/m/c-VL52ADDR.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-WWDBYXLK.js +1 -0
- package/dist/m/c-WYFDLITL.js +1 -0
- package/dist/m/c-XN6MBKI6.js +67 -0
- package/dist/m/c-XNNW2CWK.js +1 -0
- package/dist/m/c-XPELB6E5.js +1 -0
- package/dist/m/c-XQXZAO7N.js +1 -0
- package/dist/m/c-Y6USGTS5.js +5 -0
- package/dist/m/c-YDH7Q3NY.js +1 -0
- package/dist/m/c-YG2NJYS6.js +135 -0
- package/dist/m/c-Z36J63PP.js +1 -0
- package/dist/m/c-Z4VRC7GU.js +7 -0
- package/dist/m/c-Z6K52OLP.js +19 -0
- package/dist/m/c-ZDY2MDAA.js +21 -0
- package/dist/m/c-ZE46QDIH.js +1 -0
- package/dist/m/c-ZUCOKOHT.js +1 -0
- package/dist/m/core/admin-attrs-VGNLAUDK.js +1 -0
- package/dist/m/core/autosave-E4PEFPZ6.js +1 -0
- package/dist/m/core/conflict-notice-VQ6Y64PH.js +1 -0
- package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
- package/dist/m/core/host-permission-XN3K2IW3.js +1 -0
- package/dist/m/core/is-edit-mode-JXHDVGUL.js +1 -0
- package/dist/m/core/persist-HKHA66IY.js +3 -0
- package/dist/m/core/recovery-TBBSICE2.js +1 -0
- package/dist/m/core/save-WUCHTQBE.js +1 -0
- package/dist/m/core/save-core-FJVHT5M3.js +1 -0
- package/dist/m/core/snapshot-F3SWDZMM.js +1 -0
- package/dist/m/core/unsaved-warning-STKBHE6G.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-23DPTUT4.js +1 -0
- package/dist/m/internals/index-WOVAP3RU.js +1 -0
- package/dist/m/lib/cache-bust-CGWT6ZJI.js +1 -0
- package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
- package/dist/m/loader-HXX6YNH5.js +1 -0
- package/dist/m/manifest-F22DEDAC.js +1 -0
- package/dist/m/manifest-edit-C3C7376B.js +1 -0
- package/dist/m/manifest.json +3491 -0
- package/dist/m/options/options-2LH76JQ3.js +1 -0
- package/dist/m/page/editor-XUTB2HB2.js +189 -0
- package/dist/m/plugins/ai-edit-ZQ4CGWIP.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-ZIBFAA3R.js +1 -0
- package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
- package/dist/m/plugins/indicator-PV4YW2ID.js +1 -0
- package/dist/m/plugins/live-NUTTXSBS.js +1 -0
- package/dist/m/plugins/menu-HCKO3DSP.js +138 -0
- package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
- package/dist/m/plugins/page-KSUH7G6J.js +2 -0
- package/dist/m/plugins/page-view-W73OW7WI.js +1 -0
- package/dist/m/plugins/people-AJJ32YCG.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-RC3L2F6X.js +517 -0
- package/dist/m/plugins/richclay-RC3L2F6X.js.LEGAL.txt +1 -0
- package/dist/m/plugins/sortable-YRMQX3ZU.js +1 -0
- package/dist/m/plugins/source-KHK5UUSK.js +1 -0
- package/dist/m/plugins/undo-OF2L3CXU.js +1 -0
- package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
- package/dist/m/plugins/upgrade-ZNYODP5Y.js +1 -0
- package/dist/m/plugins/upload-XSGNILUY.js +1 -0
- package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +21 -0
- package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +1 -0
- package/dist/m/plugins/wire-ZWVWKF76.js +1 -0
- package/dist/m/reader-core-ILWLXVMP.js +68 -0
- package/dist/m/route/route-KOCZSEGN.js +1 -0
- package/dist/m/sync/live-sync-4AUVXVJU.js +1 -0
- package/dist/m/ui/index-EVHKMKBL.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 +3800 -0
- package/dist/page/themes/light-1.css +406 -0
- package/dist/server/core.cjs +23662 -22860
- 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/conflict-download.js +22 -0
- package/src/core/conflict-notice-ui.js +1173 -0
- package/src/core/conflict-notice.js +140 -697
- package/src/core/edit-mode.js +147 -14
- package/src/core/etag.js +30 -12
- package/src/core/host-meta.js +72 -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 +109 -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 +805 -118
- package/src/core/snapshot.js +121 -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 +141 -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 +79 -5
- 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 +17 -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/checklist.js +1 -1
- package/src/page/chrome.js +411 -61
- package/src/page/csv-import.js +17 -2
- package/src/page/document-title-dom.js +40 -0
- package/src/page/document-title-runtime.js +76 -0
- package/src/page/document-title.js +9 -0
- package/src/page/drag.js +48 -13
- package/src/page/drop.js +2 -1
- package/src/page/editor.js +330 -86
- package/src/page/feature-loader.js +72 -0
- package/src/page/features/board.js +12 -0
- package/src/page/features/code.js +13 -0
- package/src/page/features/media.js +290 -0
- package/src/page/features/registry.js +23 -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-gate.js +179 -0
- package/src/page/format-level.js +157 -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 +18 -25
- package/src/page/items.js +52 -11
- package/src/page/link.js +1 -0
- package/src/page/markdown.js +86 -1
- package/src/page/media-views.js +3 -0
- package/src/page/media.js +33 -245
- package/src/page/outline-cache.js +11 -0
- package/src/page/placeholder.js +27 -7
- package/src/page/projection.js +196 -50
- 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 +7 -4
- package/src/page/schema.js +14 -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/light-1.css +139 -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.js +390 -107
- package/src/page/ybind/awareness.js +172 -0
- package/src/page/ybind/binding.js +9 -1
- 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 +25 -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 +84 -22
- package/src/page/ybind/recovery-proof.js +191 -0
- package/src/page/ybind/recovery-replay.js +130 -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 +282 -34
- 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 +1022 -275
- 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/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 +535 -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/core/save.js
CHANGED
|
@@ -21,17 +21,17 @@ import {
|
|
|
21
21
|
saveFateIsUnknown,
|
|
22
22
|
withKeepalive
|
|
23
23
|
} from "./save-core.js";
|
|
24
|
-
import {
|
|
24
|
+
import { captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone, releaseCaptureArtifact } from "./snapshot.js";
|
|
25
|
+
import { saveLimit, saveLimits, inspectSaveSize } from "./save-size.js";
|
|
25
26
|
import { paintSignature } from "../lib/derived.js";
|
|
26
27
|
import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
|
|
27
|
-
import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
28
|
-
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
28
|
+
import { seedEtag, learnConditionalSaves, lastSeenEtag, recordEtag } from "./etag.js";
|
|
29
|
+
import { startDirtyGate, gateCaptureToken, gateClearIfUnchanged, gateRevision, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
29
30
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
30
31
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
31
32
|
import { registerStyleAdoption } from "../lib/style-adoption.js";
|
|
32
|
-
import { styleComparisonStripper } from "./style-comparisons.js";
|
|
33
33
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
|
|
34
|
-
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
34
|
+
import { logSaveCheck, logBaseline } from "../lib/autosave-debug-gate.js";
|
|
35
35
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
36
36
|
// A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
|
|
37
37
|
// flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
|
|
@@ -39,6 +39,8 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
|
|
|
39
39
|
// side sees a half-built module.
|
|
40
40
|
import { bytesUnsaved } from "./unsaved-warning.js";
|
|
41
41
|
import { bootPhase } from "./boot-phase.js";
|
|
42
|
+
import { createAutosaveScheduler } from "../lib/autosave-scheduler.js";
|
|
43
|
+
import { noteRecoveryCapture, noteRecoverySettled } from "./recovery.js";
|
|
42
44
|
|
|
43
45
|
// Keep this library's own root state out of the saved bytes.
|
|
44
46
|
//
|
|
@@ -229,7 +231,12 @@ export function suspendAutosave() {
|
|
|
229
231
|
export function resumeAutosave() {
|
|
230
232
|
if (autosaveSuspended === 0) return;
|
|
231
233
|
autosaveSuspended--;
|
|
232
|
-
if (autosaveSuspended > 0
|
|
234
|
+
if (autosaveSuspended > 0) return;
|
|
235
|
+
if (soonDeferred) {
|
|
236
|
+
soonDeferred = false;
|
|
237
|
+
soonTimer = setTimeout(fireSoon, 0);
|
|
238
|
+
}
|
|
239
|
+
if (!autosaveMissed) return;
|
|
233
240
|
autosaveMissed = false;
|
|
234
241
|
savePageThrottled();
|
|
235
242
|
}
|
|
@@ -241,6 +248,251 @@ export function autosaveIsSuspended() {
|
|
|
241
248
|
return autosaveSuspended > 0;
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
// ============================================
|
|
252
|
+
// THE SAVE SESSION
|
|
253
|
+
// ============================================
|
|
254
|
+
//
|
|
255
|
+
// One lifetime per admitted editing session: `start` when the page admits input,
|
|
256
|
+
// `quiesce` while an exit is being decided, `resume` when that exit is refused, `stop`
|
|
257
|
+
// once it is committed. What the session owns is ADMISSION: whether an ordinary save and
|
|
258
|
+
// the automatic lane may write right now.
|
|
259
|
+
//
|
|
260
|
+
// What it does not own is the page's saved bytes. A quiesce is not a reload, so the
|
|
261
|
+
// session never runs the boot settle again, never takes a capture for itself and never
|
|
262
|
+
// writes a baseline: the bytes an exit is reconciled against are the ones the last
|
|
263
|
+
// accepted save (or the served file) left behind, and the next real save carries the
|
|
264
|
+
// snapshot's own capture identity.
|
|
265
|
+
//
|
|
266
|
+
// Work that arrives while admission is closed stays pending and stays visible. A denied
|
|
267
|
+
// save writes nothing, clears no gate token and keeps the explicit-save intent armed, so
|
|
268
|
+
// the page keeps reporting the bytes it still holds; the session also records the dirty
|
|
269
|
+
// gate's revision, so an exit can prove nothing new arrived while it was closed instead
|
|
270
|
+
// of assuming quiet.
|
|
271
|
+
let quiescedSessions = 0;
|
|
272
|
+
let parkedWork = false;
|
|
273
|
+
let parkedAutosave = false;
|
|
274
|
+
// Whether the page still held bytes no save had written when its session stopped. A
|
|
275
|
+
// view-time write is not that: an entry takes the page as it stands. Work an editing
|
|
276
|
+
// session left unsaved is the person's, and absorbing it into a baseline would make it
|
|
277
|
+
// read as saved, so the next entry refuses instead.
|
|
278
|
+
let unsavedAtStop = false;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Whether a save may go out right now.
|
|
282
|
+
*
|
|
283
|
+
* An open session admits everything. A closed one admits only the caller that holds a
|
|
284
|
+
* permit minted for the generation currently owning the page — the exit's own barrier.
|
|
285
|
+
*/
|
|
286
|
+
export function savesAdmitted(permit = null) {
|
|
287
|
+
return quiescedSessions === 0 || permitHolds(permit);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Edited bytes a closed session parked, that no accepted save has covered since. The DOM
|
|
292
|
+
* still holds them and the host does not, in whatever mode the page is in by then, so the
|
|
293
|
+
* close warning reads this rather than the committed mode.
|
|
294
|
+
*/
|
|
295
|
+
export function sessionParked() {
|
|
296
|
+
return parkedWork;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The bytes a stopping session leaves behind, that no save has written: parked work is
|
|
300
|
+
// work even when the page has been put back since, and a capture that throws counts as
|
|
301
|
+
// work, because "nothing is unsaved" is the one answer that can lose something.
|
|
302
|
+
function heldWork() {
|
|
303
|
+
if (parkedWork) return true;
|
|
304
|
+
try {
|
|
305
|
+
const current = captureForDirtyCheck({ flushUndo: false });
|
|
306
|
+
if (styleAdoptionPending(current)) return false;
|
|
307
|
+
return current !== lastSavedDirty;
|
|
308
|
+
} catch {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* One editing session's admission lifetime.
|
|
315
|
+
*
|
|
316
|
+
* `changedWhileClosed` answers the exit's own question — did the page gain work while
|
|
317
|
+
* admission was closed? — from the gate's revision, which is why a quiesce costs no
|
|
318
|
+
* capture. `stop` releases the hold without replaying anything: the exit that stops a
|
|
319
|
+
* session has already accounted for the bytes, and a refused one resumes instead.
|
|
320
|
+
*/
|
|
321
|
+
export function saveSession() {
|
|
322
|
+
let phase = "idle";
|
|
323
|
+
let revisionAtQuiesce = 0;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
get phase() {
|
|
327
|
+
return phase;
|
|
328
|
+
},
|
|
329
|
+
get open() {
|
|
330
|
+
return phase === "open";
|
|
331
|
+
},
|
|
332
|
+
changedWhileClosed() {
|
|
333
|
+
return phase === "quiesced" && gateRevision() !== revisionAtQuiesce;
|
|
334
|
+
},
|
|
335
|
+
start() {
|
|
336
|
+
if (phase !== "idle") return;
|
|
337
|
+
phase = "open";
|
|
338
|
+
},
|
|
339
|
+
quiesce() {
|
|
340
|
+
if (phase !== "open") return;
|
|
341
|
+
phase = "quiesced";
|
|
342
|
+
revisionAtQuiesce = gateRevision();
|
|
343
|
+
quiescedSessions++;
|
|
344
|
+
},
|
|
345
|
+
resume() {
|
|
346
|
+
if (phase !== "quiesced") return;
|
|
347
|
+
phase = "open";
|
|
348
|
+
quiescedSessions--;
|
|
349
|
+
if (quiescedSessions > 0) return;
|
|
350
|
+
replayParkedAutosave();
|
|
351
|
+
releaseDeferredSoon();
|
|
352
|
+
},
|
|
353
|
+
stop() {
|
|
354
|
+
if (phase === "stopped") return;
|
|
355
|
+
const wasQuiesced = phase === "quiesced";
|
|
356
|
+
// A session that ran and is now over answers for the bytes it leaves behind, so an
|
|
357
|
+
// entry after it can tell the person's work from what a script did in view.
|
|
358
|
+
if ((wasQuiesced || phase === "open") && heldWork()) unsavedAtStop = true;
|
|
359
|
+
phase = "stopped";
|
|
360
|
+
if (!wasQuiesced) return;
|
|
361
|
+
quiescedSessions--;
|
|
362
|
+
if (quiescedSessions === 0) releaseDeferredSoon();
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ============================================
|
|
368
|
+
// THE MODE PARTICIPANT
|
|
369
|
+
// ============================================
|
|
370
|
+
//
|
|
371
|
+
// A live entry imports this module while the page is still in view, so the installs the
|
|
372
|
+
// boot road performs at evaluation are inert there. The participant is how an entry asks
|
|
373
|
+
// for them: `prepare` builds the session, `enable` performs the installs once per page,
|
|
374
|
+
// and `adoptEntryBaseline` takes the entry's baseline from the represented file.
|
|
375
|
+
|
|
376
|
+
// Page-lifetime: a second entry starts its session and installs nothing again.
|
|
377
|
+
let servicesInstalled = false;
|
|
378
|
+
// The last accepted save, as the receipt an exit hands the next reader.
|
|
379
|
+
let acceptedSave = null;
|
|
380
|
+
// The version the kept baselines were taken at. A second entry can then tell the person's
|
|
381
|
+
// work from what the file delivered.
|
|
382
|
+
let baselineEtag = null;
|
|
383
|
+
let baselineKept = false;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The bytes and stamp of this session's last accepted save, or null when it made none.
|
|
387
|
+
*
|
|
388
|
+
* A save belongs to the session it was made in: an exit hands the next reader its
|
|
389
|
+
* own save, never an older session's, or a clean session would hand back a file
|
|
390
|
+
* another tab has since replaced.
|
|
391
|
+
*/
|
|
392
|
+
export function acceptedReceipt(generation = saveGenerationAt) {
|
|
393
|
+
if (!acceptedSave || acceptedSave.generation !== generation) return null;
|
|
394
|
+
const { bytes, etag } = acceptedSave;
|
|
395
|
+
return { bytes, etag };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Adopt the represented file as this session's baseline, after every document transform
|
|
400
|
+
* is installed. One capture, both baselines, the settled mark and its event: the boot
|
|
401
|
+
* settle's job without a second settle over the reader's DOM.
|
|
402
|
+
*
|
|
403
|
+
* A second entry keeps the first one's baseline, taken at a version of the file. A page
|
|
404
|
+
* that moved while it was in view is taken as it now stands: a clock, a counter or a
|
|
405
|
+
* reader's own repaint is the page's doing, not the person's, and refusing it would block
|
|
406
|
+
* every entry for as long as that write stays. What is refused is the one case that is
|
|
407
|
+
* the person's: the session it ran left work no save wrote, and nothing says the file
|
|
408
|
+
* moved — which a stamp the host never answered and a baseline this page never kept both
|
|
409
|
+
* leave open.
|
|
410
|
+
*/
|
|
411
|
+
export function adoptEntryBaseline({ represented } = {}) {
|
|
412
|
+
const token = gateCaptureToken();
|
|
413
|
+
const { forComparison, forDirty } = captureForComparisonAndDirty();
|
|
414
|
+
const etag = typeof represented?.etag === "string" && represented.etag ? represented.etag : null;
|
|
415
|
+
|
|
416
|
+
// Work a session left unsaved is the person's. A version the file has moved past is
|
|
417
|
+
// the page's own doing, and refusing it would block every entry for as long as that
|
|
418
|
+
// write stays; a stamp that is missing, or a baseline this page never kept, cannot say
|
|
419
|
+
// the file did not move, so those are refused exactly as the stamped path refuses them.
|
|
420
|
+
const moved = baselineKept && etag !== null && etag !== baselineEtag;
|
|
421
|
+
if (!moved && unsavedAtStop && (forComparison !== lastSavedContents || forDirty !== lastSavedDirty)) {
|
|
422
|
+
return { ok: false, code: "pending-input" };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
unsavedAtStop = false;
|
|
426
|
+
setLastSavedBaselines(forComparison, forDirty);
|
|
427
|
+
baselineContents = forComparison;
|
|
428
|
+
baselineActive = false;
|
|
429
|
+
baselineSettledAt = true;
|
|
430
|
+
baselineEtag = etag;
|
|
431
|
+
baselineKept = true;
|
|
432
|
+
gateClearIfUnchanged(token);
|
|
433
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
434
|
+
document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
|
|
435
|
+
return { ok: true };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The host's conditional-save capability is learned from discovery, which is a request
|
|
439
|
+
// of its own, and the commit that enters edit cannot wait for it. The session keeps the
|
|
440
|
+
// promise and the save path waits on it, so the first save after a live entry still
|
|
441
|
+
// carries If-Match instead of going out unconditional because the answer was in flight.
|
|
442
|
+
let capabilityKnown = Promise.resolve(false);
|
|
443
|
+
let capabilityPending = false;
|
|
444
|
+
const learnCapability = () => {
|
|
445
|
+
capabilityPending = true;
|
|
446
|
+
return learnConditionalSaves()
|
|
447
|
+
.catch(() => false)
|
|
448
|
+
.then((supported) => {
|
|
449
|
+
capabilityPending = false;
|
|
450
|
+
return supported;
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The save system as a mode participant: a fresh session plus the page's installs on a
|
|
456
|
+
* live entry, and, on adoption, a session in phase `open` over what the import already
|
|
457
|
+
* installed and nothing new.
|
|
458
|
+
*/
|
|
459
|
+
export function modeParticipant() {
|
|
460
|
+
return {
|
|
461
|
+
id: "clay:save",
|
|
462
|
+
order: 10,
|
|
463
|
+
prepare(context = {}) {
|
|
464
|
+
const session = saveSession();
|
|
465
|
+
if (context.initial === true) session.start();
|
|
466
|
+
return {
|
|
467
|
+
enable() {
|
|
468
|
+
// Every session learns the capability and records the stamp it represents:
|
|
469
|
+
// a live entry included, and a re-entry after the stamp advanced in view.
|
|
470
|
+
capabilityKnown = learnCapability();
|
|
471
|
+
const etag = context.represented?.etag;
|
|
472
|
+
if (typeof etag === "string" && etag) recordEtag(etag);
|
|
473
|
+
if (context.initial === true) return;
|
|
474
|
+
installOnce();
|
|
475
|
+
session.start();
|
|
476
|
+
},
|
|
477
|
+
quiesce: () => session.quiesce(),
|
|
478
|
+
resume: () => session.resume(),
|
|
479
|
+
dispose: () => session.stop(),
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// What `init` installed for a boot in edit. The baseline is not among it: `adoptEntryBaseline`
|
|
486
|
+
// settles that from the represented file, and the boot settle would settle over the reader's DOM.
|
|
487
|
+
function installOnce() {
|
|
488
|
+
if (servicesInstalled) return;
|
|
489
|
+
servicesInstalled = true;
|
|
490
|
+
startDirtyGate();
|
|
491
|
+
initUserGesture();
|
|
492
|
+
initSaveKeyboardShortcut();
|
|
493
|
+
initHyperclaySaveButton();
|
|
494
|
+
}
|
|
495
|
+
|
|
244
496
|
// ============================================
|
|
245
497
|
// THE ALL-SAVE HOLD
|
|
246
498
|
// ============================================
|
|
@@ -371,6 +623,69 @@ function skipped_(msg) {
|
|
|
371
623
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
372
624
|
}
|
|
373
625
|
|
|
626
|
+
// A save asked for while a session has admission closed. It writes nothing, so nothing
|
|
627
|
+
// about the page's state may move: no capture, no gate token, no baseline. Only work that
|
|
628
|
+
// would be lost is recorded, so a save asked for on a clean page cannot make the close
|
|
629
|
+
// warning fire later for a page that lost nothing.
|
|
630
|
+
function sessionClosed(msg) {
|
|
631
|
+
if (pageMaybeDirty() || hasUnsavedState()) parkedWork = true;
|
|
632
|
+
return skipped_(msg);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The exit's own flush is not an ordinary save: it is the barrier writing the bytes the
|
|
636
|
+
// exit is about to account for, so it runs under a permit. Nothing else does — a Cmd+S or
|
|
637
|
+
// a [trigger-save] press while admission is closed stays parked.
|
|
638
|
+
//
|
|
639
|
+
// A permit is an opaque identity bound to ONE mode generation: it is minted by the
|
|
640
|
+
// controller that owns that generation, it stops admitting the moment the next generation
|
|
641
|
+
// commits, and it is revoked when the barrier that owns it finishes. That is what keeps a
|
|
642
|
+
// delayed callback from writing across a switch, and it is why there is no public `force`
|
|
643
|
+
// flag here: nothing a page can call mints one of these.
|
|
644
|
+
const livePermits = new Set();
|
|
645
|
+
let saveGenerationAt = 0;
|
|
646
|
+
|
|
647
|
+
/** The mode generation ordinary saves are being admitted against. */
|
|
648
|
+
export function saveGeneration() {
|
|
649
|
+
return saveGenerationAt;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* A new generation has committed. Permits minted for the old one stop admitting: an exit
|
|
654
|
+
* that was flushing generation N must not write once generation N+1 owns the page.
|
|
655
|
+
*/
|
|
656
|
+
export function setSaveGeneration(generation) {
|
|
657
|
+
if (typeof generation === 'number') saveGenerationAt = generation;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Mint a permit for one generation. Opaque, and only this module reads what it means. */
|
|
661
|
+
export function openSavePermit(generation = saveGenerationAt) {
|
|
662
|
+
const permit = Object.freeze({ generation });
|
|
663
|
+
livePermits.add(permit);
|
|
664
|
+
return permit;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Revoke a permit: the barrier that owned it is over. Idempotent. */
|
|
668
|
+
export function closeSavePermit(permit) {
|
|
669
|
+
livePermits.delete(permit);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function permitHolds(permit) {
|
|
673
|
+
return !!permit && livePermits.has(permit) && permit.generation === saveGenerationAt;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Tell the recovery service how this capture settled.
|
|
678
|
+
*
|
|
679
|
+
* One place, so every outcome is reported exactly once: a confirmed success is the
|
|
680
|
+
* only one that can acknowledge a recovery copy, and it acknowledges only what this
|
|
681
|
+
* capture's own bytes covered. A skip, a refusal, an error and an unknown outcome
|
|
682
|
+
* release the capture and leave every copy where it is.
|
|
683
|
+
*/
|
|
684
|
+
function settleRecoveryCapture(captureId, result) {
|
|
685
|
+
if (captureId == null) return;
|
|
686
|
+
noteRecoverySettled({ captureId, ok: result.ok === true, etag: result.etag ?? null });
|
|
687
|
+
}
|
|
688
|
+
|
|
374
689
|
/**
|
|
375
690
|
* Apply one save result to the page's state.
|
|
376
691
|
*
|
|
@@ -381,18 +696,32 @@ function skipped_(msg) {
|
|
|
381
696
|
* either.
|
|
382
697
|
*/
|
|
383
698
|
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null, quietConflict = false) {
|
|
699
|
+
settleRecoveryCapture(captureId, result);
|
|
384
700
|
if (result.ok) {
|
|
701
|
+
// These bytes are on the host now, so nothing a closed session parked is still
|
|
702
|
+
// waiting for a write.
|
|
703
|
+
parkedWork = false;
|
|
385
704
|
// Both baselines advance from the SAME pre-request capture, never from the
|
|
386
705
|
// live DOM, so an edit made while the request was on the wire stays unsaved.
|
|
387
706
|
setLastSavedBaselines(forComparison, forDirty);
|
|
388
707
|
lastSavedBytes = forSave;
|
|
708
|
+
// The receipt a mode exit hands the next reader, and the version the baselines above
|
|
709
|
+
// were taken at: both describe the bytes the host just accepted.
|
|
710
|
+
acceptedSave = { bytes: forSave, etag: lastSeenEtag(), generation: saveGenerationAt };
|
|
711
|
+
baselineEtag = acceptedSave.etag;
|
|
712
|
+
baselineKept = true;
|
|
713
|
+
// These bytes are on the host now, so nothing an earlier session left is still only here.
|
|
714
|
+
unsavedAtStop = false;
|
|
389
715
|
unsavedChanges = false;
|
|
390
716
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
391
717
|
// changed while this save was on the wire.
|
|
392
718
|
gateClearIfUnchanged(gateToken);
|
|
393
719
|
// The server's severity rides through untouched: a save can land AND carry a
|
|
394
720
|
// warning, and the UI module is what decides how to render that.
|
|
395
|
-
setSaveState('saved', result.msg || 'Saved', result.msgType
|
|
721
|
+
setSaveState('saved', result.msg || 'Saved', result.msgType, {
|
|
722
|
+
etag: result.etag ?? null,
|
|
723
|
+
captureId
|
|
724
|
+
});
|
|
396
725
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
397
726
|
releaseConflictHold();
|
|
398
727
|
} else if (result.msgType === 'conflict') {
|
|
@@ -458,10 +787,18 @@ function drainPendingSave() {
|
|
|
458
787
|
savePage();
|
|
459
788
|
return;
|
|
460
789
|
}
|
|
790
|
+
// Through the scheduler, not straight back into the automatic lane: this runs
|
|
791
|
+
// inside the completed attempt, and a second full capture in the same task is
|
|
792
|
+
// exactly the work the split into admission-then-capture exists to remove.
|
|
461
793
|
pendingAutosave = false;
|
|
462
|
-
|
|
794
|
+
autosaveScheduler.request();
|
|
463
795
|
}
|
|
464
796
|
|
|
797
|
+
// The host's advertised save ceiling, and a measurement of bytes that are not this
|
|
798
|
+
// page's document. Neither sends anything; `inspect` also captures nothing and
|
|
799
|
+
// claims nothing, because an import preview asks about candidate bytes.
|
|
800
|
+
export { saveLimits, inspectSaveSize };
|
|
801
|
+
|
|
465
802
|
// State accessors for autosave module
|
|
466
803
|
export function getUnsavedChanges() { return unsavedChanges; }
|
|
467
804
|
export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
@@ -494,26 +831,49 @@ export function setLastSavedContents(val) {
|
|
|
494
831
|
setLastSavedBaselines(val, val);
|
|
495
832
|
}
|
|
496
833
|
|
|
834
|
+
const monotonic = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
|
|
835
|
+
|
|
497
836
|
/**
|
|
498
|
-
*
|
|
837
|
+
* One save attempt, explicit or automatic.
|
|
499
838
|
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
839
|
+
* Both kinds take the same single combined capture — the save domain, the
|
|
840
|
+
* autosave comparison domain and the dirty domain all come from one live snapshot
|
|
841
|
+
* — and differ only in the question they ask of it. An explicit attempt asks the
|
|
842
|
+
* dirty question, so an edit inside a batching region is written. An automatic one
|
|
843
|
+
* asks the autosave question under the load-time baseline veto, so neither boot
|
|
844
|
+
* churn nor batching churn saves on its own.
|
|
503
845
|
*
|
|
846
|
+
* @param {{kind: 'explicit' | 'automatic'}} options
|
|
504
847
|
* @param {Function} callback - Optional callback for custom handling
|
|
505
848
|
* @returns {Promise<{msg: string, msgType: string}>}
|
|
506
849
|
*/
|
|
507
|
-
|
|
850
|
+
function attemptSave(options, callback = () => {}) {
|
|
851
|
+
// The first save of a session still learning the host's conditional-save capability
|
|
852
|
+
// waits for that answer, so it carries If-Match when the host honours one.
|
|
853
|
+
if (capabilityPending) return capabilityKnown.then(() => attemptSave(options, callback));
|
|
854
|
+
if (!adoptionsQueued) return attemptSaveNow(options, callback);
|
|
855
|
+
return adoptionQueue.then(() => attemptSaveNow(options, callback));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function attemptSaveNow({ kind, permit = null }, callback = () => {}) {
|
|
859
|
+
const explicit = kind === 'explicit';
|
|
860
|
+
|
|
508
861
|
return new Promise((resolve) => {
|
|
509
862
|
if (!isEditMode && !window.clay?.testMode) {
|
|
510
|
-
clearExplicitSave();
|
|
863
|
+
if (explicit) clearExplicitSave();
|
|
511
864
|
const skipped = skipped_('Not in edit mode');
|
|
512
865
|
callback(skipped);
|
|
513
866
|
return resolve(skipped);
|
|
514
867
|
}
|
|
515
868
|
|
|
516
|
-
|
|
869
|
+
// A session has admission closed: the page is between modes, and neither lane writes
|
|
870
|
+
// until it decides. An automatic attempt is remembered so `resume` replays it.
|
|
871
|
+
if (!savesAdmitted(permit)) {
|
|
872
|
+
if (!explicit) parkedAutosave = true;
|
|
873
|
+
const skipped = sessionClosed('Save session is quiesced');
|
|
874
|
+
callback(skipped);
|
|
875
|
+
return resolve(skipped);
|
|
876
|
+
}
|
|
517
877
|
|
|
518
878
|
// A save is already on the wire. Remember that a newer state is waiting rather
|
|
519
879
|
// than dropping it: the in-flight request carries the older bytes, and if no
|
|
@@ -521,13 +881,50 @@ export function savePage(callback = () => {}) {
|
|
|
521
881
|
// reach disk at all.
|
|
522
882
|
// Not cleared here: pendingSave means this request is deferred, not
|
|
523
883
|
// abandoned, and drainPendingSave is what eventually sends it.
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
884
|
+
if (explicit) {
|
|
885
|
+
if (heldForPreview(callback, resolve)) return;
|
|
886
|
+
if (isSaveInProgress()) {
|
|
887
|
+
pendingSave = true;
|
|
888
|
+
const skipped = skipped_('Save already in progress');
|
|
889
|
+
callback(skipped);
|
|
890
|
+
return resolve(skipped);
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
if (previewHold > 0) {
|
|
894
|
+
previewAutosaveMissed = true;
|
|
895
|
+
const skipped = skipped_(saveHoldReason());
|
|
896
|
+
callback(skipped);
|
|
897
|
+
return resolve(skipped);
|
|
898
|
+
}
|
|
899
|
+
if (isSaveInProgress() && !baselineActive && ownSaveOnWire) {
|
|
900
|
+
pendingAutosave = true;
|
|
901
|
+
const skipped = skipped_('Save already in progress');
|
|
902
|
+
callback(skipped);
|
|
903
|
+
return resolve(skipped);
|
|
904
|
+
}
|
|
905
|
+
// The dirty gate counts every change no save has accounted for, and it is
|
|
906
|
+
// the cheap half of the old dirtyMatchesLastSave check. When it says clean
|
|
907
|
+
// there is nothing to compare, so answer like the old comparison did and
|
|
908
|
+
// take no capture at all: no snapshot hooks, no sync serialization, no
|
|
909
|
+
// clay:snapshot-ready. While the page is still settling the capture itself
|
|
910
|
+
// is the veto, so the gate cannot stand in for it yet.
|
|
911
|
+
if (!baselineActive && !pageMaybeDirty()) {
|
|
912
|
+
const skipped = skipped_('No changes to save');
|
|
913
|
+
callback(skipped);
|
|
914
|
+
return resolve(skipped);
|
|
915
|
+
}
|
|
529
916
|
}
|
|
530
917
|
|
|
918
|
+
// The source map asks a save to wait here: after the gates, before the capture.
|
|
919
|
+
const prepare = window.clay?.source?.ensureForSave?.();
|
|
920
|
+
if (prepare) return prepare.then(() => resolve(attemptSaveNow({ kind, permit }, callback)));
|
|
921
|
+
|
|
922
|
+
// From here this call answers: it either writes, or reports why it did not,
|
|
923
|
+
// and the soon() callers waiting on the save queued behind the one that was
|
|
924
|
+
// on the wire when they asked get that answer.
|
|
925
|
+
const soonQueued = explicit ? takeSoonQueued() : [];
|
|
926
|
+
const settleSoonQueued = (result) => soonQueued.forEach((done) => done(result));
|
|
927
|
+
|
|
531
928
|
// Check if offline - set DOM state immediately for UI feedback
|
|
532
929
|
// but still try the fetch (navigator.onLine can be wrong)
|
|
533
930
|
const wasOffline = !navigator.onLine;
|
|
@@ -535,40 +932,106 @@ export function savePage(callback = () => {}) {
|
|
|
535
932
|
setOfflineStateQuiet();
|
|
536
933
|
}
|
|
537
934
|
|
|
538
|
-
// Single capture: clone once, get
|
|
935
|
+
// Single capture: clone once, get all three versions
|
|
539
936
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
540
937
|
// forComparison additionally strips every autosave-off region
|
|
541
|
-
|
|
938
|
+
const startedAt = monotonic();
|
|
939
|
+
let capture, forSave, forComparison, forDirty, captureId;
|
|
542
940
|
const gateToken = gateCaptureToken();
|
|
543
941
|
try {
|
|
544
|
-
|
|
942
|
+
capture = captureForSaveAndComparison({ requestRefresh: true, forRequest: true });
|
|
943
|
+
({ forSave, forComparison, forDirty, captureId } = capture);
|
|
545
944
|
} catch (err) {
|
|
546
|
-
console.error('savePage: captureForSaveAndComparison failed
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
945
|
+
console.error(`${explicit ? 'savePage' : 'savePageThrottled'}: captureForSaveAndComparison failed`, err);
|
|
946
|
+
if (explicit) {
|
|
947
|
+
clearExplicitSave();
|
|
948
|
+
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
949
|
+
} else {
|
|
950
|
+
setSaveState('error', err.message);
|
|
951
|
+
}
|
|
952
|
+
const result = { msg: err.message, msgType: 'error', code: explicit ? (err.code ?? null) : null, etag: null };
|
|
550
953
|
if (typeof callback === 'function') {
|
|
551
954
|
callback(result);
|
|
552
955
|
}
|
|
553
|
-
|
|
956
|
+
resolve(result);
|
|
957
|
+
settleSoonQueued(result);
|
|
958
|
+
return;
|
|
554
959
|
}
|
|
960
|
+
const preparedMs = monotonic() - startedAt;
|
|
961
|
+
|
|
962
|
+
// The recovery hook sees the accepted capture's exact bytes, before anything is
|
|
963
|
+
// sent, so a copy can be proved to be in them rather than merely to be nearby.
|
|
964
|
+
noteRecoveryCapture({ captureId, html: forSave });
|
|
965
|
+
|
|
966
|
+
if (explicit) {
|
|
967
|
+
// An explicit save asks the DIRTY question: write anything the person would
|
|
968
|
+
// otherwise lose, including an edit inside a batching region that was never
|
|
969
|
+
// going to autosave itself.
|
|
970
|
+
unsavedChanges = (forDirty !== lastSavedDirty);
|
|
971
|
+
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
972
|
+
|
|
973
|
+
// Skip if content hasn't changed. Clearing the explicit intent here is the
|
|
974
|
+
// whole point of scoping it: pressing Save on a clean page sends nothing, and
|
|
975
|
+
// leaving "a human asked for this" armed would hand it to the next
|
|
976
|
+
// background write and hide exactly the clobber the guard watches for.
|
|
977
|
+
if (!unsavedChanges) {
|
|
978
|
+
clearExplicitSave();
|
|
979
|
+
gateClearIfUnchanged(gateToken);
|
|
980
|
+
releaseCaptureArtifact(capture);
|
|
981
|
+
const skipped = skipped_('No changes to save');
|
|
982
|
+
settleRecoveryCapture(captureId, skipped);
|
|
983
|
+
callback(skipped);
|
|
984
|
+
resolve(skipped);
|
|
985
|
+
settleSoonQueued(skipped);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
// For autosave: while the page is still settling, content must differ from
|
|
990
|
+
// BOTH the load-time baseline and the last save, so module setup churn cannot
|
|
991
|
+
// trigger a save. Once settled, the baseline veto is disarmed and only the
|
|
992
|
+
// last save matters — otherwise undoing back to the page's original state
|
|
993
|
+
// can never be persisted, because it matches the baseline forever.
|
|
994
|
+
const differsFromBaseline = !baselineActive || forComparison !== baselineContents;
|
|
995
|
+
const differsFromLastSave = forComparison !== lastSavedContents;
|
|
996
|
+
|
|
997
|
+
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
998
|
+
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
999
|
+
|
|
1000
|
+
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
1001
|
+
// This capture's comparison leaves out batching (no-trigger-autosave)
|
|
1002
|
+
// regions, which the gate counts, so on a page that has one the dirty
|
|
1003
|
+
// domain must match too before the gate is cleared.
|
|
1004
|
+
let clear = false;
|
|
1005
|
+
try {
|
|
1006
|
+
clear = !differsFromLastSave && (!document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
|
|
1007
|
+
|| (pageMaybeDirty() && forDirty === lastSavedDirty));
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
console.error('savePageThrottled: dirty domain check failed', err);
|
|
1010
|
+
}
|
|
1011
|
+
if (clear) gateClearIfUnchanged(gateToken);
|
|
1012
|
+
autosaveScheduler.noteCost(preparedMs);
|
|
1013
|
+
releaseCaptureArtifact(capture);
|
|
1014
|
+
const skipped = skipped_('No changes to save');
|
|
1015
|
+
settleRecoveryCapture(captureId, skipped);
|
|
1016
|
+
callback(skipped);
|
|
1017
|
+
resolve(skipped);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
555
1020
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
callback(skipped);
|
|
571
|
-
return resolve(skipped);
|
|
1021
|
+
unsavedChanges = true;
|
|
1022
|
+
|
|
1023
|
+
// A save that is not this lane's own (a raw saveHtml) may still be on the
|
|
1024
|
+
// wire. The capture has already been taken, so answer like the explicit lane
|
|
1025
|
+
// and keep these newer bytes queued behind it.
|
|
1026
|
+
if (isSaveInProgress()) {
|
|
1027
|
+
pendingSave = true;
|
|
1028
|
+
releaseCaptureArtifact(capture);
|
|
1029
|
+
const skipped = skipped_('Save already in progress');
|
|
1030
|
+
settleRecoveryCapture(captureId, skipped);
|
|
1031
|
+
callback(skipped);
|
|
1032
|
+
resolve(skipped);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
572
1035
|
}
|
|
573
1036
|
|
|
574
1037
|
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
@@ -581,16 +1044,39 @@ export function savePage(callback = () => {}) {
|
|
|
581
1044
|
saveCapturedHtml(forSave, captureId, (result) => {
|
|
582
1045
|
ownSaveOnWire = false;
|
|
583
1046
|
inFlightComparisons.delete(comparison);
|
|
1047
|
+
const completedAt = monotonic();
|
|
584
1048
|
applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
|
|
1049
|
+
if (!explicit) autosaveScheduler.noteCost(preparedMs + (monotonic() - completedAt));
|
|
585
1050
|
if (typeof callback === 'function') {
|
|
586
1051
|
callback(result);
|
|
587
1052
|
}
|
|
588
1053
|
resolve(result);
|
|
1054
|
+
settleSoonQueued(result);
|
|
589
1055
|
drainPendingSave();
|
|
590
|
-
});
|
|
1056
|
+
}, { capture });
|
|
591
1057
|
});
|
|
592
1058
|
}
|
|
593
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Save the current page with change detection and state management.
|
|
1062
|
+
*
|
|
1063
|
+
* Returns a Promise that resolves with {msg, msgType} — the same object
|
|
1064
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
1065
|
+
* msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
|
|
1066
|
+
*
|
|
1067
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
1068
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
1069
|
+
*/
|
|
1070
|
+
export function savePage(callback = () => {}) {
|
|
1071
|
+
return attemptSave({ kind: 'explicit' }, callback);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// The exit flush's own entry, inside its permit. Not exported: the only caller is the
|
|
1075
|
+
// barrier below, which is the one save a closed session must still be able to send.
|
|
1076
|
+
function savePagePermitted(permit, callback = () => {}) {
|
|
1077
|
+
return attemptSave({ kind: 'explicit', permit }, callback);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
594
1080
|
/**
|
|
595
1081
|
* Force-save the current page (skips dirty check).
|
|
596
1082
|
*
|
|
@@ -598,6 +1084,7 @@ export function savePage(callback = () => {}) {
|
|
|
598
1084
|
* @returns {Promise<{msg: string, msgType: string}>}
|
|
599
1085
|
*/
|
|
600
1086
|
export function savePageForce(callback = () => {}, { quietConflict = false } = {}) {
|
|
1087
|
+
if (capabilityPending) return capabilityKnown.then(() => savePageForce(callback, { quietConflict }));
|
|
601
1088
|
return new Promise((resolve) => {
|
|
602
1089
|
if (!isEditMode && !window.clay?.testMode) {
|
|
603
1090
|
const skipped = skipped_('Not in edit mode');
|
|
@@ -605,6 +1092,14 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
|
|
|
605
1092
|
return resolve(skipped);
|
|
606
1093
|
}
|
|
607
1094
|
|
|
1095
|
+
// A forced save is still an ordinary one for admission: only the exit's own flush
|
|
1096
|
+
// carries the permit.
|
|
1097
|
+
if (!savesAdmitted()) {
|
|
1098
|
+
const skipped = sessionClosed('Save session is quiesced');
|
|
1099
|
+
callback(skipped);
|
|
1100
|
+
return resolve(skipped);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
608
1103
|
if (heldForPreview(callback, resolve)) return;
|
|
609
1104
|
|
|
610
1105
|
if (isSaveInProgress()) {
|
|
@@ -614,15 +1109,19 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
|
|
|
614
1109
|
return resolve(skipped);
|
|
615
1110
|
}
|
|
616
1111
|
|
|
1112
|
+
const prepare = window.clay?.source?.ensureForSave?.();
|
|
1113
|
+
if (prepare) return prepare.then(() => resolve(savePageForce(callback, { quietConflict })));
|
|
1114
|
+
|
|
617
1115
|
const wasOffline = !navigator.onLine;
|
|
618
1116
|
if (wasOffline) {
|
|
619
1117
|
setOfflineStateQuiet();
|
|
620
1118
|
}
|
|
621
1119
|
|
|
622
|
-
let forSave, forComparison, forDirty, captureId;
|
|
1120
|
+
let capture, forSave, forComparison, forDirty, captureId;
|
|
623
1121
|
const gateToken = gateCaptureToken();
|
|
624
1122
|
try {
|
|
625
|
-
|
|
1123
|
+
capture = captureForSaveAndComparison({ requestRefresh: true, forRequest: true });
|
|
1124
|
+
({ forSave, forComparison, forDirty, captureId } = capture);
|
|
626
1125
|
} catch (err) {
|
|
627
1126
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
628
1127
|
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
@@ -633,6 +1132,8 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
|
|
|
633
1132
|
return resolve(result);
|
|
634
1133
|
}
|
|
635
1134
|
|
|
1135
|
+
noteRecoveryCapture({ captureId, html: forSave });
|
|
1136
|
+
|
|
636
1137
|
setSavingState();
|
|
637
1138
|
|
|
638
1139
|
const comparison = { forComparison, forDirty };
|
|
@@ -647,7 +1148,7 @@ export function savePageForce(callback = () => {}, { quietConflict = false } = {
|
|
|
647
1148
|
}
|
|
648
1149
|
resolve(result);
|
|
649
1150
|
drainPendingSave();
|
|
650
|
-
});
|
|
1151
|
+
}, { capture });
|
|
651
1152
|
});
|
|
652
1153
|
}
|
|
653
1154
|
|
|
@@ -703,24 +1204,29 @@ function nextEvent(name, deadline) {
|
|
|
703
1204
|
// host but the page holds work a save cannot write, registered through
|
|
704
1205
|
// clay.registerUnsavedState; only a person can resolve that). Never overwrites.
|
|
705
1206
|
// `keepalive` is a transport hint honoured under 60 KB of UTF-8.
|
|
706
|
-
export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
|
|
1207
|
+
export async function flushSave({ keepalive = false, timeoutMs = 15000, permit = null } = {}) {
|
|
707
1208
|
if (!isEditMode) return { state: 'view', etag: null };
|
|
708
1209
|
const deadline = Date.now() + timeoutMs;
|
|
1210
|
+
// A caller with no permit of its own is the plain `clay.save.flush`: it mints one for
|
|
1211
|
+
// the generation that owns the page now, so a flush during a transition is still a
|
|
1212
|
+
// write in this generation and never the next one's.
|
|
1213
|
+
const held = permit || openSavePermit();
|
|
709
1214
|
let timer = null;
|
|
710
1215
|
const timeout = new Promise((_, reject) => {
|
|
711
1216
|
timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
|
|
712
1217
|
});
|
|
713
1218
|
try {
|
|
714
|
-
return await Promise.race([flushBytes(keepalive, deadline), timeout]);
|
|
1219
|
+
return await Promise.race([flushBytes(keepalive, deadline, held), timeout]);
|
|
715
1220
|
} catch (err) {
|
|
716
1221
|
if (err && typeof err.state === 'string') throw err;
|
|
717
1222
|
throw flushError(err?.message || 'Save failed', 'failed');
|
|
718
1223
|
} finally {
|
|
719
1224
|
clearTimeout(timer);
|
|
1225
|
+
if (!permit) closeSavePermit(held);
|
|
720
1226
|
}
|
|
721
1227
|
}
|
|
722
1228
|
|
|
723
|
-
async function flushBytes(keepalive, deadline) {
|
|
1229
|
+
async function flushBytes(keepalive, deadline, permit) {
|
|
724
1230
|
// Load-time module churn is not the person's work: autosave never saves it, and
|
|
725
1231
|
// neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
|
|
726
1232
|
if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
|
|
@@ -736,7 +1242,7 @@ async function flushBytes(keepalive, deadline) {
|
|
|
736
1242
|
if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
|
|
737
1243
|
return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
|
|
738
1244
|
}
|
|
739
|
-
const result = await withKeepalive(keepalive, () =>
|
|
1245
|
+
const result = await withKeepalive(keepalive, () => savePagePermitted(permit));
|
|
740
1246
|
// The lane was busy (a drained pending save is on the wire): wait it out next turn.
|
|
741
1247
|
if (result.msgType === 'skipped') continue;
|
|
742
1248
|
if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
|
|
@@ -795,8 +1301,70 @@ export function replacePageWith(url) {
|
|
|
795
1301
|
});
|
|
796
1302
|
}
|
|
797
1303
|
|
|
798
|
-
//
|
|
799
|
-
|
|
1304
|
+
// The automatic lane's own throttle. It is the floor under the scheduler: a call
|
|
1305
|
+
// that arrives inside the window rides the trailing attempt instead of capturing.
|
|
1306
|
+
const throttledAutomatic = throttle(callback => attemptSave({ kind: 'automatic' }, callback), 1200);
|
|
1307
|
+
|
|
1308
|
+
const SOON_MS = 250;
|
|
1309
|
+
const SOON_MAX_MS = 1000;
|
|
1310
|
+
let soonTimer = null;
|
|
1311
|
+
let soonStarted = 0;
|
|
1312
|
+
let soonWaiters = [];
|
|
1313
|
+
// Callers whose timer fired while a save was on the wire. Their call queued one
|
|
1314
|
+
// behind it, and they wait for THAT save's result: the skipped answer to their
|
|
1315
|
+
// own call describes nothing they asked for.
|
|
1316
|
+
let soonQueuedWaiters = [];
|
|
1317
|
+
// The timer fired while autosave was suspended. The callers keep waiting and
|
|
1318
|
+
// resumeAutosave arms the timer again.
|
|
1319
|
+
let soonDeferred = false;
|
|
1320
|
+
|
|
1321
|
+
const takeSoonQueued = () => {
|
|
1322
|
+
const waiters = soonQueuedWaiters;
|
|
1323
|
+
soonQueuedWaiters = [];
|
|
1324
|
+
return waiters;
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
function fireSoon() {
|
|
1328
|
+
soonTimer = null;
|
|
1329
|
+
if (autosaveSuspended > 0 || !savesAdmitted()) {
|
|
1330
|
+
soonDeferred = true;
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const waiters = soonWaiters;
|
|
1334
|
+
soonWaiters = [];
|
|
1335
|
+
if (isSaveInProgress()) {
|
|
1336
|
+
soonQueuedWaiters.push(...waiters);
|
|
1337
|
+
savePage();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
savePage().then((result) => waiters.forEach((done) => done(result)));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export function savePageSoon() {
|
|
1344
|
+
const now = Date.now();
|
|
1345
|
+
if (soonTimer === null) soonStarted = now;
|
|
1346
|
+
else clearTimeout(soonTimer);
|
|
1347
|
+
return new Promise((resolve) => {
|
|
1348
|
+
soonWaiters.push(resolve);
|
|
1349
|
+
soonTimer = setTimeout(fireSoon, Math.max(0, Math.min(SOON_MS, soonStarted + SOON_MAX_MS - now)));
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// The automatic attempt a closed session parked. Replayed when admission comes back, so
|
|
1354
|
+
// an edit typed while the page was between modes still reaches disk.
|
|
1355
|
+
function replayParkedAutosave() {
|
|
1356
|
+
if (!parkedAutosave) return;
|
|
1357
|
+
parkedAutosave = false;
|
|
1358
|
+
savePageThrottled();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// A soon() timer that fired while admission was closed: the callers are still waiting on
|
|
1362
|
+
// a save that has not been attempted, so arm it again in the same way resumeAutosave does.
|
|
1363
|
+
function releaseDeferredSoon() {
|
|
1364
|
+
if (!soonDeferred) return;
|
|
1365
|
+
soonDeferred = false;
|
|
1366
|
+
soonTimer = setTimeout(fireSoon, 0);
|
|
1367
|
+
}
|
|
800
1368
|
|
|
801
1369
|
// Baseline for autosave comparison
|
|
802
1370
|
let baselineContents = '';
|
|
@@ -808,6 +1376,9 @@ let baselineActive = true;
|
|
|
808
1376
|
// from an editmode:resource script) are in the page by now.
|
|
809
1377
|
let baselineSettledAt = false;
|
|
810
1378
|
export function baselineSettled() { return baselineSettledAt; }
|
|
1379
|
+
// True once a person edited the page during the boot settle. The settled capture is
|
|
1380
|
+
// skipped then, so the edit is never absorbed into the saved baseline.
|
|
1381
|
+
let bootUserEdited = false;
|
|
811
1382
|
|
|
812
1383
|
// A repaint that leaves the page's painted values different from the file's means
|
|
813
1384
|
// the file is stale: an agent wrote a record without painting it. One clean,
|
|
@@ -847,8 +1418,9 @@ export function currentPaintSignature() {
|
|
|
847
1418
|
export function schedulePaintHeal(fileSignature) {
|
|
848
1419
|
if (!isEditMode || isDraftMode) return;
|
|
849
1420
|
clearTimeout(paintHealTimer);
|
|
850
|
-
paintHealTimer = setTimeout(() => {
|
|
1421
|
+
paintHealTimer = setTimeout(async () => {
|
|
851
1422
|
paintHealTimer = null;
|
|
1423
|
+
if (adoptionsQueued) await adoptionQueue;
|
|
852
1424
|
const onDisk = typeof fileSignature === 'function' ? fileSignature() : fileSignature;
|
|
853
1425
|
if (onDisk == null || !autosaveActive() || !baselineSettled() || isSaveConflicted() || autosaveIsSuspended()) return;
|
|
854
1426
|
if (captureForDirtyCheck({ flushUndo: false }) !== lastSavedDirty) return;
|
|
@@ -859,17 +1431,145 @@ export function schedulePaintHeal(fileSignature) {
|
|
|
859
1431
|
}, PAINT_HEAL_MIN_MS + Math.random() * PAINT_HEAL_SPREAD_MS);
|
|
860
1432
|
}
|
|
861
1433
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1434
|
+
// parse5 is 38.5 KB Brotli, and an owned style is the only reason this module names
|
|
1435
|
+
// it, so the stripper loads the first time a script writes one. `clay.style()` stays
|
|
1436
|
+
// synchronous: it queues the identity here and the strip runs when the module lands.
|
|
1437
|
+
// Every reader that compares bytes awaits the queue first; see
|
|
1438
|
+
// `styleAdoptionPending` for the one synchronous reader that cannot.
|
|
1439
|
+
let adoptionStripper = null;
|
|
1440
|
+
let adoptionLoad = null;
|
|
1441
|
+
let adoptionsQueued = 0;
|
|
1442
|
+
let adoptionQueue = Promise.resolve();
|
|
1443
|
+
// A person edited since the oldest adoption now queued. The style write is not one,
|
|
1444
|
+
// so the pending window cannot speak for the page's bytes once this is true.
|
|
1445
|
+
let editedSinceAdoption = false;
|
|
1446
|
+
// The identities whose strip has not landed yet, oldest first, with the exact
|
|
1447
|
+
// attribute bytes their saved tag carries.
|
|
1448
|
+
const pendingStyles = [];
|
|
1449
|
+
|
|
1450
|
+
function loadAdoptionStripper() {
|
|
1451
|
+
adoptionLoad ??= import("./style-comparisons.js").catch(error => {
|
|
1452
|
+
adoptionLoad = null;
|
|
1453
|
+
console.error('style-comparisons failed to load', error);
|
|
1454
|
+
return null;
|
|
1455
|
+
});
|
|
1456
|
+
return adoptionLoad;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
async function stripAdoptedStyle(identity) {
|
|
1460
|
+
try {
|
|
1461
|
+
if (!adoptionStripper) adoptionStripper = (await loadAdoptionStripper())?.styleComparisonStripper ?? null;
|
|
1462
|
+
if (!adoptionStripper) return;
|
|
1463
|
+
const strip = adoptionStripper(identity);
|
|
1464
|
+
lastSavedContents = strip(lastSavedContents);
|
|
1465
|
+
lastSavedDirty = strip(lastSavedDirty);
|
|
1466
|
+
baselineContents = strip(baselineContents);
|
|
1467
|
+
for (const comparison of inFlightComparisons) {
|
|
1468
|
+
comparison.forComparison = strip(comparison.forComparison);
|
|
1469
|
+
comparison.forDirty = strip(comparison.forDirty);
|
|
1470
|
+
}
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
console.error('style adoption failed', error);
|
|
1473
|
+
} finally {
|
|
1474
|
+
adoptionsQueued--;
|
|
1475
|
+
pendingStyles.shift();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// The bytes a saved owned tag of this identity carries in the stored baselines. The
|
|
1480
|
+
// capture writes attribute values with `&` and `"` escaped and every other character
|
|
1481
|
+
// as itself, so the marker is the exact form the serializer wrote.
|
|
1482
|
+
const attrValue = value => value.replace(/&/g, '&').replace(/"/g, '"');
|
|
1483
|
+
|
|
1484
|
+
function styleIdentityMarkers(identity) {
|
|
1485
|
+
if (identity.name !== undefined) {
|
|
1486
|
+
const markers = [`data-name="${attrValue(identity.name)}"`];
|
|
1487
|
+
if (identity.legacyId !== undefined) markers.push(`id="${attrValue(identity.legacyId)}"`);
|
|
1488
|
+
return markers;
|
|
870
1489
|
}
|
|
1490
|
+
// The identity is a base URL, while the file may hold the href relative to the
|
|
1491
|
+
// page, so the path alone counts too.
|
|
1492
|
+
const markers = [attrValue(identity.href)];
|
|
1493
|
+
try {
|
|
1494
|
+
const { pathname } = new URL(identity.href);
|
|
1495
|
+
if (pathname) markers.push(attrValue(pathname));
|
|
1496
|
+
} catch {}
|
|
1497
|
+
return markers;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** Whether a stored baseline, or a save already in flight, carries the owned tag. */
|
|
1501
|
+
function baselineCarriesStyle(markers) {
|
|
1502
|
+
const stored = [lastSavedContents, lastSavedDirty, baselineContents];
|
|
1503
|
+
for (const comparison of inFlightComparisons) stored.push(comparison.forComparison, comparison.forDirty);
|
|
1504
|
+
return stored.some(html => html && markers.some(marker => marker && html.includes(marker)));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// A stored page with the pending owned tags taken out, found by the exact attribute
|
|
1508
|
+
// bytes the capture writes and expanded to the element around them. The real strip is
|
|
1509
|
+
// parse5's; this only answers "is anything ELSE different", so a span it misses or
|
|
1510
|
+
// over-reaches leaves the page reading dirty, never clean.
|
|
1511
|
+
function withoutOwnedStyles(html, pending) {
|
|
1512
|
+
const spans = [];
|
|
1513
|
+
for (const { markers, tag } of pending) {
|
|
1514
|
+
for (const marker of markers) {
|
|
1515
|
+
for (let at = html.indexOf(marker); at >= 0; at = html.indexOf(marker, at + marker.length)) {
|
|
1516
|
+
const start = html.lastIndexOf('<', at);
|
|
1517
|
+
if (start < 0 || html.slice(start + 1, start + 1 + tag.length).toLowerCase() !== tag) continue;
|
|
1518
|
+
const close = tag === 'style' ? html.indexOf('</style', at) : at;
|
|
1519
|
+
if (close < 0) continue;
|
|
1520
|
+
const end = html.indexOf('>', close);
|
|
1521
|
+
if (end >= 0) spans.push([start, end + 1]);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
1526
|
+
let out = '';
|
|
1527
|
+
let offset = 0;
|
|
1528
|
+
for (const [from, to] of spans) {
|
|
1529
|
+
if (from < offset) continue;
|
|
1530
|
+
out += html.slice(offset, from);
|
|
1531
|
+
offset = to;
|
|
1532
|
+
}
|
|
1533
|
+
return out + html.slice(offset);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
registerStyleAdoption(identity => {
|
|
1537
|
+
const markers = styleIdentityMarkers(identity);
|
|
1538
|
+
// Nothing to strip means nothing to load: a Document whose file has no such tag
|
|
1539
|
+
// never asks for parse5. A false hit only costs the parse, which decides precisely.
|
|
1540
|
+
if (!baselineCarriesStyle(markers)) return;
|
|
1541
|
+
if (!adoptionsQueued) editedSinceAdoption = false;
|
|
1542
|
+
adoptionsQueued++;
|
|
1543
|
+
pendingStyles.push({ markers, tag: identity.name !== undefined ? 'style' : 'link' });
|
|
1544
|
+
adoptionQueue = adoptionQueue.then(() => stripAdoptedStyle(identity));
|
|
871
1545
|
});
|
|
872
1546
|
|
|
1547
|
+
/** Resolves once every style written so far has been stripped from the baselines. */
|
|
1548
|
+
export function styleAdoptionsSettled() {
|
|
1549
|
+
return adoptionQueue;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* The window a style adoption opens. A fresh capture leaves an owned style out
|
|
1554
|
+
* (writing one marks it no-dirty), while the stored baselines still carry it until
|
|
1555
|
+
* the stripper lands, so a synchronous reader in that window reads the page as
|
|
1556
|
+
* dirty. That is only the adoption's own doing while nothing else differs: an edit
|
|
1557
|
+
* since the adoption was queued, work an owner registered, a save on the wire, or
|
|
1558
|
+
* any other byte `current` (the caller's own capture) carries turn this off. A clean
|
|
1559
|
+
* adoption is clean before and after the baseline settles, so no settle condition
|
|
1560
|
+
* belongs here.
|
|
1561
|
+
*/
|
|
1562
|
+
export function styleAdoptionPending(current) {
|
|
1563
|
+
if (!adoptionsQueued) return false;
|
|
1564
|
+
if (editedSinceAdoption || hasUnsavedState()) return false;
|
|
1565
|
+
// Parked work is work too: a save a closed session refused leaves the baselines
|
|
1566
|
+
// untouched, so without this the adoption window would speak for the page's bytes
|
|
1567
|
+
// while they are still waiting to be written.
|
|
1568
|
+
if (isSaveInProgress() || pendingSave || pendingAutosave || parkedWork) return false;
|
|
1569
|
+
if (typeof current !== 'string') return false;
|
|
1570
|
+
return current === withoutOwnedStyles(lastSavedDirty, pendingStyles);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
873
1573
|
// ============================================
|
|
874
1574
|
// BASELINE CAPTURE (Settled Signal)
|
|
875
1575
|
// ============================================
|
|
@@ -904,7 +1604,6 @@ export function releaseBootSettle() {
|
|
|
904
1604
|
function initBaselineCapture() {
|
|
905
1605
|
if (!isEditMode) return;
|
|
906
1606
|
|
|
907
|
-
let userEdited = false;
|
|
908
1607
|
let settled = false;
|
|
909
1608
|
let unsubscribeMutation = null;
|
|
910
1609
|
|
|
@@ -917,15 +1616,18 @@ function initBaselineCapture() {
|
|
|
917
1616
|
baselineContents = immediateContents;
|
|
918
1617
|
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
919
1618
|
|
|
920
|
-
// Track user edits to avoid overwriting real changes
|
|
921
|
-
|
|
1619
|
+
// Track user edits to avoid overwriting real changes. Every real edit counts,
|
|
1620
|
+
// whatever the target and however the page takes it: the editor handles keys and
|
|
1621
|
+
// clicks itself, a checklist box cancels mousedown, a picker or a menu item is a
|
|
1622
|
+
// click, a drop is neither key nor click. A style write is not an edit (it is
|
|
1623
|
+
// marked no-dirty), so a clean adoption stays clean. These stay attached after the
|
|
1624
|
+
// settle: an adoption queued later opens the same window again.
|
|
1625
|
+
const userEditEvents = ['keydown', 'pointerdown', 'drop', 'input', 'change', 'paste'];
|
|
1626
|
+
const modifierKeys = new Set(['Shift', 'Control', 'Alt', 'Meta']);
|
|
922
1627
|
const markUserEdited = (e) => {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
target.tagName === 'TEXTAREA' ||
|
|
927
|
-
target.tagName === 'SELECT';
|
|
928
|
-
if (isEditable) userEdited = true;
|
|
1628
|
+
if (e.type === 'keydown' && modifierKeys.has(e.key)) return;
|
|
1629
|
+
editedSinceAdoption = true;
|
|
1630
|
+
if (!settled) bootUserEdited = true;
|
|
929
1631
|
};
|
|
930
1632
|
userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
|
|
931
1633
|
|
|
@@ -936,12 +1638,11 @@ function initBaselineCapture() {
|
|
|
936
1638
|
|
|
937
1639
|
// Cleanup listeners
|
|
938
1640
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
939
|
-
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
940
1641
|
|
|
941
1642
|
// Adoption only removes owned styles from comparison state. It does not
|
|
942
1643
|
// advance this revision. A save or sync installation does, even when its
|
|
943
1644
|
// comparison bytes match, so settle cannot absorb a later unsent edit.
|
|
944
|
-
if (!
|
|
1645
|
+
if (!bootUserEdited && baselineRevision === immediateRevision) {
|
|
945
1646
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
946
1647
|
const gateToken = gateCaptureToken();
|
|
947
1648
|
const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
|
|
@@ -952,7 +1653,7 @@ function initBaselineCapture() {
|
|
|
952
1653
|
gateClearIfUnchanged(gateToken);
|
|
953
1654
|
logBaseline('settled capture', `${contents.length} chars`);
|
|
954
1655
|
} else {
|
|
955
|
-
logBaseline('settled skipped',
|
|
1656
|
+
logBaseline('settled skipped', bootUserEdited ? 'user edited' : 'save occurred during settle');
|
|
956
1657
|
}
|
|
957
1658
|
|
|
958
1659
|
// The load-time veto has done its job. It exists so setup churn from modules
|
|
@@ -1026,6 +1727,15 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
1026
1727
|
return Promise.resolve(skipped);
|
|
1027
1728
|
}
|
|
1028
1729
|
|
|
1730
|
+
// The page is between modes: no automatic attempt is admitted, and the activity that
|
|
1731
|
+
// asked for one is remembered so `resume` replays it instead of losing the edit.
|
|
1732
|
+
if (!savesAdmitted()) {
|
|
1733
|
+
parkedAutosave = true;
|
|
1734
|
+
const skipped = sessionClosed('Save session is quiesced');
|
|
1735
|
+
callback(skipped);
|
|
1736
|
+
return Promise.resolve(skipped);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1029
1739
|
// Every autosave path lands here — the mutation-driven one, the [persist] input
|
|
1030
1740
|
// timer, and live-sync's convergence save after a protected apply — which is
|
|
1031
1741
|
// why the suspension lives at this one entry rather than at each caller.
|
|
@@ -1047,60 +1757,29 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
1047
1757
|
return Promise.resolve(skipped);
|
|
1048
1758
|
}
|
|
1049
1759
|
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// matters — otherwise undoing back to the page's original state can never be
|
|
1054
|
-
// persisted, because it matches the baseline forever.
|
|
1055
|
-
// Compare directly - stored versions are already stripped
|
|
1056
|
-
const gateToken = gateCaptureToken();
|
|
1057
|
-
let currentForCompare;
|
|
1058
|
-
let dirtyMatchesLastSave;
|
|
1059
|
-
try {
|
|
1060
|
-
currentForCompare = captureForComparison();
|
|
1061
|
-
// This comparison leaves out batching (no-trigger-autosave) regions, which the
|
|
1062
|
-
// gate counts, so on a page that has one the dirty domain must match too.
|
|
1063
|
-
dirtyMatchesLastSave = () => !document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
|
|
1064
|
-
|| (pageMaybeDirty() && captureForDirtyCheck({ flushUndo: false }) === lastSavedDirty);
|
|
1065
|
-
} catch (err) {
|
|
1066
|
-
console.error('savePageThrottled: captureForComparison failed', err);
|
|
1067
|
-
setSaveState('error', err.message);
|
|
1068
|
-
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
1069
|
-
callback(result);
|
|
1070
|
-
return Promise.resolve(result);
|
|
1071
|
-
}
|
|
1072
|
-
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
1073
|
-
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
1074
|
-
|
|
1075
|
-
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
1076
|
-
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
1077
|
-
|
|
1078
|
-
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
1079
|
-
let clear = false;
|
|
1080
|
-
try {
|
|
1081
|
-
clear = !differsFromLastSave && dirtyMatchesLastSave();
|
|
1082
|
-
} catch (err) {
|
|
1083
|
-
console.error('savePageThrottled: captureForDirtyCheck failed', err);
|
|
1084
|
-
}
|
|
1085
|
-
if (clear) gateClearIfUnchanged(gateToken);
|
|
1086
|
-
const skipped = skipped_('No changes to save');
|
|
1087
|
-
callback(skipped);
|
|
1088
|
-
return Promise.resolve(skipped);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
unsavedChanges = true;
|
|
1760
|
+
// The capture happens inside the attempt, after the throttle admits it: the
|
|
1761
|
+
// page is not copied to answer "is there anything to save?" any more, and an
|
|
1762
|
+
// admitted attempt takes one snapshot for both the decision and the bytes.
|
|
1092
1763
|
// The throttled promise can reject now that a throwing save no longer strands
|
|
1093
1764
|
// its callers (see throttle.js), and both autosave call sites are fire-and-forget,
|
|
1094
1765
|
// with no reach into this module's error path. Catch it here, the one place that
|
|
1095
1766
|
// owns that path, so every caller keeps the never-rejects contract savePage
|
|
1096
1767
|
// documents and a throw surfaces as a failed save instead of an unhandled rejection.
|
|
1097
|
-
return
|
|
1768
|
+
return throttledAutomatic(callback).catch(err => {
|
|
1098
1769
|
const result = { ok: false, msg: err?.message || 'Save failed', msgType: 'error', code: null, etag: null };
|
|
1099
1770
|
setSaveState('error', result.msg);
|
|
1100
1771
|
return result;
|
|
1101
1772
|
});
|
|
1102
1773
|
}
|
|
1103
1774
|
|
|
1775
|
+
// The one automatic-save scheduler. Every automatic trigger (a mutation, a
|
|
1776
|
+
// [persist] input, a merge that owes a save) only reports activity or intent to
|
|
1777
|
+
// it, and it admits the attempt through this module's automatic lane.
|
|
1778
|
+
export const autosaveScheduler = createAutosaveScheduler({
|
|
1779
|
+
now: monotonic,
|
|
1780
|
+
run: () => savePageThrottled()
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1104
1783
|
/**
|
|
1105
1784
|
* Initialize keyboard shortcut for save (CMD/CTRL+S)
|
|
1106
1785
|
*/
|
|
@@ -1109,6 +1788,9 @@ export function initSaveKeyboardShortcut() {
|
|
|
1109
1788
|
let isMac = window.navigator.platform.match("Mac");
|
|
1110
1789
|
let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
1111
1790
|
if (metaKeyPressed && event.keyCode == 83) {
|
|
1791
|
+
// The listener outlives its session, so the mode is read now: after an exit the
|
|
1792
|
+
// page is the reader's and the browser's own Save has to work.
|
|
1793
|
+
if (!isEditMode) return;
|
|
1112
1794
|
event.preventDefault();
|
|
1113
1795
|
// isTrusted, so a script calling dispatchEvent cannot manufacture human
|
|
1114
1796
|
// provenance for a write the person never asked for.
|
|
@@ -1149,6 +1831,11 @@ export function init() {
|
|
|
1149
1831
|
// first save's own response has already supplied one.
|
|
1150
1832
|
seedEtag();
|
|
1151
1833
|
|
|
1834
|
+
// The same discovery document states the host's save ceiling. Asked here for the
|
|
1835
|
+
// same reason and the same way: joined to the memoized answer, never awaited, so
|
|
1836
|
+
// the limit is known long before the first save and no save ever waits on it.
|
|
1837
|
+
saveLimit();
|
|
1838
|
+
|
|
1152
1839
|
// Every editable page, not just autosave pages. A manual-save page makes
|
|
1153
1840
|
// exactly the saves a person asked for, and used to report all of them as
|
|
1154
1841
|
// background writes because this was installed behind the autosave gate.
|