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