@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.
Files changed (250) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +5570 -4173
  11. package/dist/m/attrs/{save-freeze-MH3AOD56.js → save-freeze-PUYX32AK.js} +1 -1
  12. package/dist/m/{c-BOMQIQ3V.js → c-2EFMJYGJ.js} +1 -1
  13. package/dist/m/c-2EPHRF47.js +1 -0
  14. package/dist/m/{c-JD2VFAYG.js → c-2SUFMUZX.js} +2 -2
  15. package/dist/m/{c-CITLTVQ4.js → c-2TJCFPX4.js} +1 -1
  16. package/dist/m/c-33SS5A4Y.js +1 -0
  17. package/dist/m/{c-V2FPBRO6.js → c-34J5HC7N.js} +1 -1
  18. package/dist/m/c-3F3AADKG.js +1 -0
  19. package/dist/m/{c-TCFVG3I4.js → c-3S3647J6.js} +1 -1
  20. package/dist/m/{c-GY4BREDT.js → c-3S5TNRZA.js} +1 -1
  21. package/dist/m/{c-GDNZEOOZ.js → c-3WX5FABT.js} +1 -1
  22. package/dist/m/{c-AI3H6ETU.js → c-3XGLJAS7.js} +2 -2
  23. package/dist/m/c-42WBOKFM.js +1 -0
  24. package/dist/m/c-47M4QHCK.js +1 -0
  25. package/dist/m/{c-UOUUI265.js → c-4M2T676V.js} +1 -1
  26. package/dist/m/{c-GEAG5XAK.js → c-4TP3UXBT.js} +1 -1
  27. package/dist/m/{c-IESDKFO6.js → c-5ELELG2F.js} +1 -1
  28. package/dist/m/{c-X3V4RWMA.js → c-5ZYLTN3Q.js} +1 -1
  29. package/dist/m/{c-5PZYDWTW.js → c-67TBU56I.js} +1 -1
  30. package/dist/m/{c-WNOLMHI2.js → c-6BGJAJ67.js} +1 -1
  31. package/dist/m/{c-4Q6QIODW.js → c-755ZOHOM.js} +1 -1
  32. package/dist/m/c-7Q4XYDF4.js +1 -0
  33. package/dist/m/c-7RHOMAMR.js +1 -0
  34. package/dist/m/c-A5LASUEZ.js +1 -0
  35. package/dist/m/{c-IL5IVTOG.js → c-ABOBD7F3.js} +1 -1
  36. package/dist/m/c-AQVQKFKB.js +1 -0
  37. package/dist/m/{c-QDVG4NAZ.js → c-ARO6CPMA.js} +1 -1
  38. package/dist/m/{c-ORLPQFNU.js → c-AZM34SPO.js} +1 -1
  39. package/dist/m/{c-XUZMRQ7O.js → c-BCS3AZQZ.js} +1 -1
  40. package/dist/m/c-BOS7GONO.js +1 -0
  41. package/dist/m/{c-SUAC5RSG.js → c-C5UGNOC2.js} +1 -1
  42. package/dist/m/c-C6DXZIXD.js +1 -0
  43. package/dist/m/{c-WSTUCBHV.js → c-CPRGR2II.js} +1 -1
  44. package/dist/m/{c-NICICT4P.js → c-DEYHCYJB.js} +1 -1
  45. package/dist/m/{c-WGQVSKKC.js → c-DYTBSJBI.js} +1 -1
  46. package/dist/m/{c-DT7E76Z2.js → c-DZT6W2NG.js} +1 -1
  47. package/dist/m/{c-O2QZDVZ2.js → c-EEWPNUM2.js} +1 -1
  48. package/dist/m/{c-MFB74XVN.js → c-EHAR4RJJ.js} +2 -2
  49. package/dist/m/c-EHJWIGWN.js +1 -0
  50. package/dist/m/c-EIDZHQHZ.js +1 -0
  51. package/dist/m/{c-V2V5FKXX.js → c-EIUKME3W.js} +1 -1
  52. package/dist/m/c-ELXMSEN5.js +1 -0
  53. package/dist/m/c-EXZOA6XY.js +7 -0
  54. package/dist/m/{c-FKTALXNV.js → c-EYFMAR3M.js} +1 -1
  55. package/dist/m/c-FC5RUFPR.js +1 -0
  56. package/dist/m/{c-FBOO2UY3.js → c-FUN6AKFE.js} +1 -1
  57. package/dist/m/{c-V4B3RUWB.js → c-GFASNR55.js} +1 -1
  58. package/dist/m/{c-XMIDHEKF.js → c-GY36NO7C.js} +1 -1
  59. package/dist/m/{c-UD4VDBGJ.js → c-H3M6UDNV.js} +1 -1
  60. package/dist/m/{c-SFZRY6RT.js → c-H47C6PJZ.js} +1 -1
  61. package/dist/m/{c-MUM53J5J.js → c-H4M2T4A6.js} +1 -1
  62. package/dist/m/{c-U7F66I64.js → c-HYNM5U6B.js} +1 -1
  63. package/dist/m/c-J2YYLEVM.js +1 -0
  64. package/dist/m/c-J2ZW4HMR.js +1 -0
  65. package/dist/m/{c-WCG6AKA7.js → c-JC4RZW4E.js} +1 -1
  66. package/dist/m/c-K2ZHQMPF.js +1 -0
  67. package/dist/m/c-KD2ME5UN.js +2 -0
  68. package/dist/m/{c-Z2OZFX3N.js → c-L6KVXPBO.js} +1 -1
  69. package/dist/m/{c-GO635CRU.js → c-LAKBBJOU.js} +2 -2
  70. package/dist/m/{c-4AE2A2LK.js → c-LETPPG2F.js} +1 -1
  71. package/dist/m/{c-SFYTKRU7.js → c-MKAUC6DB.js} +1 -1
  72. package/dist/m/{c-WDFQ7PBU.js → c-MMEVDN6D.js} +1 -1
  73. package/dist/m/{c-LS7IVQVW.js → c-MMV5JULA.js} +1 -1
  74. package/dist/m/{c-NEQRFC4O.js → c-MVVEIJUU.js} +1 -1
  75. package/dist/m/{c-SXZYVXD6.js → c-N362HNGO.js} +1 -1
  76. package/dist/m/{c-7K4SCQB7.js → c-ND3W7D5U.js} +1 -1
  77. package/dist/m/{c-3E2IK5HU.js → c-NNHNXD6M.js} +1 -1
  78. package/dist/m/{c-D62HRSZK.js → c-NYJ577NL.js} +1 -1
  79. package/dist/m/{c-YFO6ZJLQ.js → c-O3DPFZCE.js} +1 -1
  80. package/dist/m/c-OZOACVUD.js +1 -0
  81. package/dist/m/{c-MV7TEUTD.js → c-OZVK3TFI.js} +1 -1
  82. package/dist/m/{c-ZEA3DCG3.js → c-PWILUN4T.js} +1 -1
  83. package/dist/m/{c-BCWYEG6P.js → c-QA3Q3MDI.js} +1 -1
  84. package/dist/m/{c-XHZIKRCV.js → c-QKOKMQXA.js} +1 -1
  85. package/dist/m/{c-TGIMBAWE.js → c-RBWVF4N5.js} +1 -1
  86. package/dist/m/c-RES2S5Q3.js +1 -0
  87. package/dist/m/c-RFGELYEU.js +2 -0
  88. package/dist/m/c-RGFPLQGV.js +1 -0
  89. package/dist/m/{c-RGAALCDU.js → c-SPOEDIMP.js} +1 -1
  90. package/dist/m/{c-YX2MC7YL.js → c-SRW5OBCF.js} +1 -1
  91. package/dist/m/{c-C6YZNU6E.js → c-SYOA4T7V.js} +1 -1
  92. package/dist/m/c-SYVF6ZMF.js +1 -0
  93. package/dist/m/{c-NADLYVAH.js → c-TJ5PFOJ2.js} +1 -1
  94. package/dist/m/{c-KYTVZT4D.js → c-TKV273YU.js} +1 -1
  95. package/dist/m/c-U35T5NAY.js +1 -0
  96. package/dist/m/{c-PRZNJUYZ.js → c-UEKJWXCB.js} +2 -2
  97. package/dist/m/c-UW6MMZ4V.js +1 -0
  98. package/dist/m/{c-XQTERKQA.js → c-V4DBV5ZV.js} +1 -1
  99. package/dist/m/c-VGG3UR7I.js +1 -0
  100. package/dist/m/c-VU4WEWPH.js +4 -0
  101. package/dist/m/{c-5Q5XBN56.js → c-W7LWCJHD.js} +1 -1
  102. package/dist/m/c-WHMZCRQD.js +6 -0
  103. package/dist/m/{c-WMRJKQ2V.js → c-WHQUZM2V.js} +1 -1
  104. package/dist/m/c-WOJ2RTV4.js +1 -0
  105. package/dist/m/{c-ZJG6C2WL.js → c-WOWGGNCJ.js} +1 -1
  106. package/dist/m/{c-ZORVIXUR.js → c-XHD7XJTE.js} +1 -1
  107. package/dist/m/c-XONFK3WC.js +1 -0
  108. package/dist/m/{c-HGG7M2S3.js → c-Y3J5LDKB.js} +1 -1
  109. package/dist/m/{c-JB5YU57S.js → c-Y3MPVURA.js} +1 -1
  110. package/dist/m/c-Y6HH7LTM.js +1 -0
  111. package/dist/m/{c-52OC3OH3.js → c-YRDKVEGR.js} +1 -1
  112. package/dist/m/c-YYIN4NAF.js +30 -0
  113. package/dist/m/{c-WVCT3ITX.js → c-YZGWSCMT.js} +1 -1
  114. package/dist/m/c-ZFAPQQIB.js +1 -0
  115. package/dist/m/core/{admin-attrs-ZI6K6UGM.js → admin-attrs-43JBA6JB.js} +1 -1
  116. package/dist/m/core/{autosave-BSM2ABJL.js → autosave-HD7Y7UNV.js} +1 -1
  117. package/dist/m/core/{conflict-notice-7EQLDYPA.js → conflict-notice-UNDOD4BL.js} +1 -1
  118. package/dist/m/core/{persist-2SZO3PCE.js → persist-MX3VYVBN.js} +1 -1
  119. package/dist/m/core/{save-K6Q47J2X.js → save-JZ7R4ET2.js} +1 -1
  120. package/dist/m/core/{save-core-U3Z44XBO.js → save-core-I4N6S4ED.js} +1 -1
  121. package/dist/m/core/site-edits-C22O53GS.js +1 -0
  122. package/dist/m/core/{snapshot-PWCHYFL6.js → snapshot-N5MKDGNI.js} +1 -1
  123. package/dist/m/core/{unsaved-warning-2WRZLJH5.js → unsaved-warning-BP6ZY3PG.js} +1 -1
  124. package/dist/m/internals/{index-FSLC4M7J.js → index-3LAVREAU.js} +1 -1
  125. package/dist/m/loader-DCKCZKY6.js +1 -0
  126. package/dist/m/manifest-7954CBE3.js +1 -0
  127. package/dist/m/manifest-edit-93AA79D7.js +1 -0
  128. package/dist/m/manifest.json +1240 -1179
  129. package/dist/m/page/{editor-PKSPJ2XJ.js → editor-UF3CIA6V.js} +5 -5
  130. package/dist/m/plugins/{ai-edit-3LG6MGS3.js → ai-edit-WDV2GLDU.js} +2 -2
  131. package/dist/m/plugins/{cms-T2HLI7JG.js → cms-KG6TCTR3.js} +9 -9
  132. package/dist/m/plugins/{derive-7DR5TUDH.js → derive-R4USHZ7P.js} +1 -1
  133. package/dist/m/plugins/{indicator-CTKX4EY7.js → indicator-OLPZ4WFI.js} +1 -1
  134. package/dist/m/plugins/{live-IFLL6YN5.js → live-DF5YS5AH.js} +1 -1
  135. package/dist/m/plugins/{menu-YJZ3JWBC.js → menu-ESEFQARM.js} +1 -1
  136. package/dist/m/plugins/page-GZSU77JS.js +2 -0
  137. package/dist/m/plugins/{page-view-NMCCU6MF.js → page-view-7MM3FEAR.js} +1 -1
  138. package/dist/m/plugins/{people-6WIWCZEG.js → people-EBWE337I.js} +1 -1
  139. package/dist/m/plugins/quickcrop-UHURKA2F.js +26 -0
  140. package/dist/m/plugins/richclay-MXH2GFAZ.js +549 -0
  141. package/dist/m/plugins/sortable-BAG3MSWS.js +1 -0
  142. package/dist/m/plugins/{source-7LSGOV46.js → source-OYHDXKTB.js} +1 -1
  143. package/dist/m/plugins/undo-KLHAGV75.js +1 -0
  144. package/dist/m/plugins/{upgrade-MMPZUCDM.js → upgrade-SZQJYEQQ.js} +1 -1
  145. package/dist/m/plugins/{visitor-drafts-V5YB7XDH.js → visitor-drafts-ZE6PCAFP.js} +2 -2
  146. package/dist/m/plugins/{visitor-menu-QDV2MYJN.js → visitor-menu-5XY57JQA.js} +1 -1
  147. package/dist/m/plugins/{wire-3YGUO3GE.js → wire-S5ATC4QI.js} +1 -1
  148. package/dist/m/reader-core-AHOAP5DH.js +77 -0
  149. package/dist/m/sync/{live-sync-XMXXZS3G.js → live-sync-RZQSDMZW.js} +1 -1
  150. package/dist/m/ui/index-G7ZJXNMG.js +1 -0
  151. package/dist/m/vendor/{sap-core.vendor-JANUQULT.js → sap-core.vendor-5TWOJYVM.js} +4 -4
  152. package/dist/offline-manifest.json +780 -750
  153. package/dist/server/core.cjs +4245 -2614
  154. package/dist/server/markdown.cjs +1 -0
  155. package/dist/server/markdown.mjs +7 -0
  156. package/docs/guide.md +26 -37
  157. package/entries/clay-data.js +1 -1
  158. package/entries/sap.js +5 -5
  159. package/package.json +6 -2
  160. package/packed-contract.json +3 -0
  161. package/src/attrs/save-freeze.js +1 -1
  162. package/src/core/admin-contenteditable.js +1 -1
  163. package/src/core/admin-inputs.js +1 -1
  164. package/src/core/admin-onclick.js +1 -1
  165. package/src/core/admin-resources.js +5 -2
  166. package/src/core/conflict-notice-ui.js +59 -34
  167. package/src/core/save.js +9 -9
  168. package/src/core/site-edits.js +1 -1
  169. package/src/core/snapshot.js +56 -43
  170. package/src/core/stale-host-notice.js +20 -7
  171. package/src/loader-logic.js +0 -13
  172. package/src/page/boot-pack.js +1 -0
  173. package/src/page/editor.js +10 -4
  174. package/src/page/history-intent.js +111 -4
  175. package/src/page/import-markdown.js +22 -8
  176. package/src/page/save-projection.js +1 -1
  177. package/src/page/ybind/bridge.js +14 -7
  178. package/src/page/ybind/moves.js +437 -3
  179. package/src/page/ybind/origins.js +1 -1
  180. package/src/page/ybind/recovery.js +1 -1
  181. package/src/plugins/page.js +48 -4
  182. package/src/plugins/quickcrop.js +128 -69
  183. package/src/plugins/richclay.js +136 -0
  184. package/src/plugins/sortable.js +25 -9
  185. package/src/plugins/undo.js +169 -7
  186. package/src/recovery/undo.js +14 -7
  187. package/src/server/markdown/index.js +23 -0
  188. package/src/sync/conflicts.js +3 -1
  189. package/src/sync/data-guard-notice.js +137 -18
  190. package/src/sync/live-sync.js +24 -0
  191. package/src/ui/bevel-controls.js +4 -1
  192. package/src/ui/dialogs.js +11 -7
  193. package/src/ui/modal.js +9 -7
  194. package/src/ui/toast.js +67 -32
  195. package/src/ui/vendor-skin.js +9 -4
  196. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  197. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  198. package/src/vendor/hyper-undo.vendor.js +1 -1
  199. package/src/vendor/hypercms.vendor.js +7 -7
  200. package/src/vendor/richclay.vendor.js +16 -16
  201. package/src/vendor/sap-core.vendor.js +4 -4
  202. package/tool-generation/context.md +69 -0
  203. package/tool-generation/system.md +1 -1
  204. package/dist/m/c-277QP5ET.js +0 -1
  205. package/dist/m/c-5SZ6KFR5.js +0 -1
  206. package/dist/m/c-CU33V6RW.js +0 -4
  207. package/dist/m/c-CVSWLX3X.js +0 -1
  208. package/dist/m/c-DXPOFLEJ.js +0 -1
  209. package/dist/m/c-EODTTNVA.js +0 -1
  210. package/dist/m/c-ESK4S67I.js +0 -1
  211. package/dist/m/c-FDWR3FL7.js +0 -1
  212. package/dist/m/c-FJDTDGDA.js +0 -1
  213. package/dist/m/c-FYDKYW6E.js +0 -1
  214. package/dist/m/c-HKSLOVU4.js +0 -1
  215. package/dist/m/c-IJHF4IO6.js +0 -1
  216. package/dist/m/c-IUL7PU4P.js +0 -1
  217. package/dist/m/c-JWW6BHWD.js +0 -6
  218. package/dist/m/c-K4M76VPM.js +0 -7
  219. package/dist/m/c-M7ITYKMF.js +0 -1
  220. package/dist/m/c-MR75YPRT.js +0 -1
  221. package/dist/m/c-NFQFV7OP.js +0 -2
  222. package/dist/m/c-NHXOBD6A.js +0 -1
  223. package/dist/m/c-NVU3DORS.js +0 -1
  224. package/dist/m/c-PNZIJFG7.js +0 -1
  225. package/dist/m/c-QWB53KRW.js +0 -1
  226. package/dist/m/c-RHPHMTF2.js +0 -2
  227. package/dist/m/c-RWJ3OSCP.js +0 -1
  228. package/dist/m/c-SEVOS4UE.js +0 -1
  229. package/dist/m/c-SGG6MUXW.js +0 -1
  230. package/dist/m/c-SXNSSHPP.js +0 -1
  231. package/dist/m/c-TFLRT5JX.js +0 -1
  232. package/dist/m/c-TLQINES7.js +0 -30
  233. package/dist/m/c-U65PREMC.js +0 -1
  234. package/dist/m/c-V5L6HWVL.js +0 -1
  235. package/dist/m/c-VMXCX52Q.js +0 -1
  236. package/dist/m/c-YYGUHSZR.js +0 -1
  237. package/dist/m/core/site-edits-CFMF6GHD.js +0 -1
  238. package/dist/m/loader-RC54UV7H.js +0 -1
  239. package/dist/m/manifest-3B84C829.js +0 -1
  240. package/dist/m/manifest-edit-1CB79AD4.js +0 -1
  241. package/dist/m/plugins/page-GTDPYGE4.js +0 -2
  242. package/dist/m/plugins/quickcrop-X5MRIBAP.js +0 -26
  243. package/dist/m/plugins/richclay-L3BZMX4I.js +0 -549
  244. package/dist/m/plugins/sortable-365SRRL3.js +0 -1
  245. package/dist/m/plugins/undo-UES4KS7B.js +0 -1
  246. package/dist/m/reader-core-VR7EL4WV.js +0 -77
  247. package/dist/m/ui/index-MGTWOW2C.js +0 -1
  248. /package/dist/m/{c-QTTJYJMA.js → c-FILLTJA7.js} +0 -0
  249. /package/dist/m/plugins/{quickcrop-X5MRIBAP.js.LEGAL.txt → quickcrop-UHURKA2F.js.LEGAL.txt} +0 -0
  250. /package/dist/m/plugins/{richclay-L3BZMX4I.js.LEGAL.txt → richclay-MXH2GFAZ.js.LEGAL.txt} +0 -0
@@ -1,12 +1,12 @@
1
1
  import * as Y from "../../vendor/yjs.vendor.js";
2
- import { LOCAL, REMOTE_META, mintOp } from "./layout.js";
2
+ import { LOCAL, REMOTE_META, idKey, mintOp } from "./layout.js";
3
3
  import { state as pmState } from "../../vendor/prosemirror.vendor.js";
4
4
  import { walk } from "./walk.js";
5
5
  import { docOf } from "./doc.js";
6
6
  import { createProjectionMap } from "./projection-map.js";
7
7
  import { beforeFence } from "./writer.js";
8
- import { unitIds } from "./origins.js";
9
- import { attrKey, copyActiveKey, parseGoneIntents, physicalKeyOf, recoveredRoot, removeKey, restoreKey } from "./recovery.js";
8
+ import { coversOriginal, unitIds } from "./origins.js";
9
+ import { COPY_ACTIVE, REMOVE, TEXT_DELETE, attrKey, copyActiveKey, parseGoneIntents, parsePhysicalKey, physicalKeyOf, recoveredRoot, removeKey, restoreKey, visibleOriginIndex } from "./recovery.js";
10
10
 
11
11
  const refuse = (code) => ({ status: "refused", code });
12
12
 
@@ -298,3 +298,437 @@ export function moveBlocks({ pd, view, um, origins, render, walked = null }, arg
298
298
  restoreCaret();
299
299
  return { status: "moved", ids: movedIds };
300
300
  }
301
+
302
+ const CARRY = "blockeditor-move-carry";
303
+
304
+ const COPY_ACTIVE_KEYS = `["${COPY_ACTIVE}",`;
305
+ const REMOVE_KEYS = `["${REMOVE}",`;
306
+ const TEXT_DELETE_KEYS = `["${TEXT_DELETE}",`;
307
+
308
+ const startOf = (pd, sourceKey) => {
309
+ const id = parsePhysicalKey(sourceKey);
310
+ if (!id) return null;
311
+ let it = null;
312
+ try { it = Y.getItem(pd.ydoc.store, id); } catch { return null; }
313
+ return it instanceof Y.Item && !it.deleted && it.parent === pd.flow ? it : null;
314
+ };
315
+
316
+ const targetOf = (pd, origins, walked, map, blockId) => {
317
+ let cursor = 0, seg = null;
318
+ for (const candidate of walked.visible) {
319
+ if (candidate.id === blockId) { seg = candidate; break; }
320
+ cursor += candidate.len;
321
+ }
322
+ if (!seg || seg.implicit || seg.atom || seg.opaque || !seg.brk) return null;
323
+ const places = new Map();
324
+ let head = null;
325
+ for (const [from, to] of map.physicalRanges(cursor, cursor + seg.len)) {
326
+ const ids = unitIds(pd.flow, from, to);
327
+ for (let i = 0; i < ids.length; i++) {
328
+ if (head === null) head = from;
329
+ else places.set(idKey(origins.resolve(ids[i])), from + i);
330
+ }
331
+ }
332
+ return head === null ? null : { head, places };
333
+ };
334
+
335
+ const formatsBefore = (pd, stop) => {
336
+ const out = new Map();
337
+ for (let it = pd.flow._start; it && it !== stop; it = it.right) {
338
+ if (it.deleted || !(it.content instanceof Y.ContentFormat)) continue;
339
+ if (it.content.value === null) out.delete(it.content.key);
340
+ else out.set(it.content.key, it.content.value);
341
+ }
342
+ return out;
343
+ };
344
+
345
+ const rangesOf = (ranges) => {
346
+ const set = Y.createDeleteSet();
347
+ for (const { client, clock, len } of ranges) (set.clients.get(client) ?? set.clients.set(client, []).get(client)).push({ clock, len });
348
+ return set;
349
+ };
350
+
351
+ const markerOf = (it) => it.content instanceof Y.ContentEmbed && it.content.embed?.b !== undefined;
352
+
353
+ // The block a unit was typed into: the first block marker on the chain of left neighbours
354
+ // each unit was inserted after. The chain never changes, so every tab finds the same block,
355
+ // and a word has one home copy whose owner alone may carry it.
356
+ const homeOf = (pd, homes, it) => {
357
+ const path = [];
358
+ let at = it, home = null;
359
+ for (;;) {
360
+ const known = homes.get(at);
361
+ if (known !== undefined) { home = known; break; }
362
+ path.push(at);
363
+ let next = null;
364
+ if (at.origin) try { next = Y.getItem(pd.ydoc.store, at.origin); } catch { next = null; }
365
+ if (!(next instanceof Y.Item)) break;
366
+ if (markerOf(next)) { home = next; break; }
367
+ at = next;
368
+ }
369
+ for (const item of path) homes.set(item, home);
370
+ return home;
371
+ };
372
+
373
+ // Only a copy's own words move, only deletions a person's record backs are mirrored, and the
374
+ // words join the history step that shows the region again, so that step puts each word back
375
+ // exactly where it was typed. A region a Redo hid again is only mirrored: what reached it
376
+ // since the Undo is set aside, as before the carry existed.
377
+ function carryAcross({ pd, origins, walked, placed }, owned, homes, flip = null, records = false) {
378
+ const entries = walked.hidden.filter((entry) => owned.has(entry.key));
379
+ if (!entries.length) return false;
380
+ const self = pd.ydoc.clientID;
381
+ const deletes = flip !== null || records ? parseGoneIntents(pd.gone).textDeletes.filter((record) => record.actor !== self) : [];
382
+ const removed = new Map();
383
+ const taken = new Set();
384
+ const inserts = [];
385
+ const folds = new Map();
386
+ let visible = null, map = null;
387
+ for (const entry of entries) {
388
+ const owner = owned.get(entry.key);
389
+ const { left, past } = owner;
390
+ const start = startOf(pd, entry.sourceKey);
391
+ if (!start) continue;
392
+ const live = new Map(entry.items.map((item) => [item.id, item]));
393
+ const region = [];
394
+ for (let it = start.right; it; it = it.right) {
395
+ if (it.countable && !it.deleted && !live.has(idKey(it.id))) break;
396
+ region.push(it);
397
+ }
398
+ const words = new Set(owner.copy ? region.filter((it) => !it.deleted && it.countable && !markerOf(it) && !left.has(`${idKey(it.id)}+${it.length}`) && homeOf(pd, homes, it) === start) : []);
399
+ const covering = deletes.filter((record) => region.some((it) => it.deleted && it.countable && it.id.client === record.client && record.clock < it.id.clock + it.length && it.id.clock < record.clock + record.length));
400
+ if (!words.size && !covering.length) continue;
401
+ const seen = owner.seen ?? new Map(entry.sv);
402
+ const fresh = (client, clock) => clock >= (seen.get(client) ?? 0);
403
+ const formats = words.size ? formatsBefore(pd, start) : null;
404
+ let attrs = null;
405
+ const units = [];
406
+ const shown = new Map();
407
+ for (const it of region) {
408
+ if (!it.countable) {
409
+ if (formats && it.content instanceof Y.ContentFormat && (!it.deleted || Y.isDeleted(past, it.id))) {
410
+ if (it.content.value === null) formats.delete(it.content.key);
411
+ else formats.set(it.content.key, it.content.value);
412
+ attrs = null;
413
+ }
414
+ continue;
415
+ }
416
+ const item = it.deleted ? null : live.get(idKey(it.id));
417
+ const word = words.has(it);
418
+ for (let offset = 0; offset < it.length; offset++) {
419
+ const id = { client: it.id.client, clock: it.id.clock + offset };
420
+ const source = origins.resolve(id);
421
+ const key = idKey(source);
422
+ if (!(it.deleted && covering.length && coversOriginal(covering, self, id))) shown.set(key, true);
423
+ else if (!shown.has(key) && (!owner.alive || fresh(id.client, id.clock) || owner.alive.has(idKey(id)))) shown.set(key, false);
424
+ units.push({ it, item, offset, id, source, key, attrs: word ? (attrs ??= Object.fromEntries(formats)) : null });
425
+ }
426
+ }
427
+ map ??= createProjectionMap(walked, pd.flow.length);
428
+ const target = targetOf(pd, origins, walked, map, entry.blockId);
429
+ if (!target) {
430
+ for (const it of words) left.add(`${idKey(it.id)}+${it.length}`);
431
+ continue;
432
+ }
433
+ if (covering.length) for (const [key, place] of target.places) if (shown.get(key) === false && !removed.has(place)) removed.set(place, null);
434
+ const groups = new Map();
435
+ let at = target.head + 1;
436
+ for (const unit of units) {
437
+ const { it, item, offset, id, source } = unit;
438
+ if (unit.attrs) {
439
+ if (!taken.has(idKey(id)) && !(visible ??= visibleOriginIndex(pd.flow, walked, origins))(source.client, source.clock)) {
440
+ taken.add(idKey(id));
441
+ removed.set(item.at + offset, owner);
442
+ if (owner.step && !folds.has(owner)) folds.set(owner, { deleted: [], inserted: [] });
443
+ const group = groups.get(at) ?? groups.set(at, []).get(at);
444
+ group.push(item.kind === "text" ? { ch: it.content.str[offset], attrs: unit.attrs, source } : { embed: it.content.embed, attrs: unit.attrs, source });
445
+ continue;
446
+ }
447
+ left.add(`${idKey(it.id)}+${it.length}`);
448
+ }
449
+ const place = target.places.get(unit.key);
450
+ if (place !== undefined) at = place + 1;
451
+ }
452
+ for (const [place, list] of groups) inserts.push({ at: place, units: list, owner });
453
+ }
454
+ if (!removed.size && !inserts.length) return false;
455
+ const ops = [...[...removed].map(([at, owner]) => ({ at, owner })), ...inserts].sort((a, b) => b.at - a.at || (a.units ? 1 : 0) - (b.units ? 1 : 0));
456
+ pd.ydoc.transact((tr) => {
457
+ let run = null;
458
+ // Deleting a word also deletes the format items that only it needed. The step that
459
+ // brings the word back has to bring those back with it, so it keeps all of them.
460
+ const cut = () => {
461
+ if (!run) return;
462
+ const fold = folds.get(run.owner);
463
+ const sizes = fold ? new Map([...tr.deleteSet.clients].map(([client, ranges]) => [client, ranges.length])) : null;
464
+ pd.flow.delete(run.from, run.to - run.from);
465
+ if (fold) for (const [client, ranges] of tr.deleteSet.clients) for (const { clock, len } of ranges.slice(sizes.get(client) ?? 0)) {
466
+ const struct = Y.getItem(pd.ydoc.store, { client, clock });
467
+ if (struct instanceof Y.Item) struct.keep = true;
468
+ fold.deleted.push({ client, clock, len });
469
+ }
470
+ run = null;
471
+ };
472
+ for (const op of ops) {
473
+ if (!op.units) {
474
+ if (run && op.at === run.from - 1 && op.owner === run.owner) run.from = op.at;
475
+ else { cut(); run = { from: op.at, to: op.at + 1, owner: op.owner }; }
476
+ continue;
477
+ }
478
+ cut();
479
+ const before = Y.getState(pd.ydoc.store, self);
480
+ let cursor = op.at, text = "", attrs = null;
481
+ const put = () => {
482
+ if (!text) return;
483
+ const chars = text;
484
+ beforeFence(pd, cursor, walked.fences, () => pd.flow.insert(cursor, chars, { ...attrs }));
485
+ cursor += chars.length;
486
+ text = "";
487
+ };
488
+ for (const unit of op.units) {
489
+ if (unit.ch === undefined) {
490
+ put();
491
+ beforeFence(pd, cursor, walked.fences, () => pd.flow.insertEmbed(cursor, { ...unit.embed }, { ...unit.attrs }));
492
+ cursor += 1;
493
+ continue;
494
+ }
495
+ if (text && attrsKey(unit.attrs) !== attrsKey(attrs)) put();
496
+ attrs = unit.attrs;
497
+ text += unit.ch;
498
+ }
499
+ put();
500
+ folds.get(op.owner)?.inserted.push({ client: self, clock: before, len: Y.getState(pd.ydoc.store, self) - before });
501
+ const dest = unitIds(pd.flow, op.at, cursor);
502
+ if (dest.length !== op.units.length) throw new Error(`Carried ${dest.length} units instead of ${op.units.length}`);
503
+ origins.recordSpans(dest.map((id, i) => ({ dest: id, source: op.units[i].source })));
504
+ placed.push({ ids: dest, step: op.owner.step });
505
+ }
506
+ cut();
507
+ }, CARRY);
508
+ for (const [{ step }, fold] of folds) {
509
+ step.deletions = Y.mergeDeleteSets([step.deletions, rangesOf(fold.deleted)]);
510
+ step.insertions = Y.mergeDeleteSets([step.insertions, rangesOf(fold.inserted)]);
511
+ }
512
+ return true;
513
+ }
514
+
515
+ const eachUnit = (pd, set, f) => {
516
+ for (const [client, ranges] of set.clients) for (const { clock, len } of ranges) {
517
+ for (let at = clock; at < clock + len;) {
518
+ let it = null;
519
+ try { it = Y.getItem(pd.ydoc.store, { client, clock: at }); } catch { break; }
520
+ const end = Math.min(clock + len, it.id.clock + it.length);
521
+ if (it instanceof Y.Item && it.parent === pd.flow && it.countable) for (let unit = at; unit < end; unit++) f(it, { client, clock: unit });
522
+ at = end;
523
+ }
524
+ }
525
+ };
526
+
527
+ const recorded = (records, id) => records.some((record) => record.client === id.client && id.clock >= record.clock && id.clock < record.clock + record.length);
528
+
529
+ const gone = (pd, id) => {
530
+ try { return Y.getItem(pd.ydoc.store, id).deleted; } catch { return true; }
531
+ };
532
+
533
+ // Before a step is replayed. A word it inserted that a carry or another replay moved (that
534
+ // unit and its source unit are both gone, and no person's record deleted them) is followed to
535
+ // every live unit holding it, so the typist's Undo takes the word out where it now is. A unit
536
+ // the step would re-create is left out when nothing holds its word any more, so a Redo never
537
+ // brings back a word its typist undid or a person deleted.
538
+ const followStep = (pd, origins, step) => {
539
+ const dead = [];
540
+ eachUnit(pd, step.insertions, (it, id) => { if (it.deleted) dead.push(id); });
541
+ if (!dead.length) return;
542
+ const records = parseGoneIntents(pd.gone).textDeletes;
543
+ const words = new Map();
544
+ for (const id of dead) {
545
+ const source = origins.resolve(id);
546
+ const word = words.get(idKey(source)) ?? words.set(idKey(source), { source, follow: false, redo: [] }).get(idKey(source));
547
+ if (!word.follow && !recorded(records, id) && gone(pd, source)) word.follow = true;
548
+ }
549
+ eachUnit(pd, step.deletions, (it, id) => words.get(idKey(origins.resolve(id)))?.redo.push(id));
550
+ const wanted = new Map();
551
+ for (const word of words.values()) {
552
+ if (!word.follow && !word.redo.length) continue;
553
+ word.lives = [];
554
+ (wanted.get(word.source.client) ?? wanted.set(word.source.client, []).get(word.source.client)).push(word);
555
+ }
556
+ if (!wanted.size) return;
557
+ for (let it = pd.flow._start; it; it = it.right) {
558
+ if (it.deleted || !it.countable) continue;
559
+ let at = it.id.clock;
560
+ for (const piece of origins.resolveRange(it.id.client, it.id.clock, it.length)) {
561
+ for (const word of wanted.get(piece.client) ?? []) {
562
+ const offset = word.source.clock - piece.clock;
563
+ if (offset >= 0 && offset < piece.length) word.lives.push({ client: it.id.client, clock: at + offset, len: 1 });
564
+ }
565
+ at += piece.length;
566
+ }
567
+ }
568
+ const adds = [];
569
+ for (const list of wanted.values()) for (const word of list) {
570
+ if (word.follow) adds.push(...word.lives);
571
+ if (!word.lives.length) for (const id of word.redo) adds.push({ client: id.client, clock: id.clock, len: 1 });
572
+ }
573
+ if (adds.length) step.insertions = Y.mergeDeleteSets([step.insertions, rangesOf(adds)]);
574
+ };
575
+
576
+ const holds = (it, id) => id !== null && it.id.client === id.client && id.clock >= it.id.clock && id.clock < it.id.clock + it.length;
577
+
578
+ // A block marker another tab inserted between a carried word and the unit the carry put it
579
+ // after, without having seen the word (its right neighbour lies past the word). Yjs orders such
580
+ // ties by client number, so the word can end up inside that tab's new block. Returns the marker
581
+ // nearest the unit the carry put the word after.
582
+ const cutIn = (pd, ids) => {
583
+ let item;
584
+ try { item = Y.getItem(pd.ydoc.store, ids[0]); } catch { return null; }
585
+ if (item.id.clock !== ids[0].clock || !item.origin) return null;
586
+ const between = [item];
587
+ let edge = null;
588
+ for (let it = item.left; it && !holds(it, item.origin); it = it.left) {
589
+ if (markerOf(it) && it.id.client !== pd.ydoc.clientID && !between.some((other) => holds(other, it.rightOrigin))) edge = it;
590
+ between.push(it);
591
+ }
592
+ return edge;
593
+ };
594
+
595
+ // The carrying tab alone puts such a word back in front of that marker, into the block it was
596
+ // carried to. If that block is hidden by then, the word is set aside there as any hidden word is.
597
+ const reseat = (pd, origins, walked, placed) => {
598
+ const kept = [], moved = [];
599
+ for (const group of placed) {
600
+ const live = group.ids.filter((id) => !gone(pd, id));
601
+ if (!live.length) continue;
602
+ const edge = cutIn(pd, group.ids);
603
+ if (edge) moved.push({ group, edge, live: new Set(live.map(idKey)) });
604
+ else kept.push(group);
605
+ }
606
+ if (!moved.length) return placed;
607
+ const self = pd.ydoc.clientID;
608
+ pd.ydoc.transact(() => {
609
+ for (const { group, edge, live } of moved) {
610
+ const formats = new Map();
611
+ const units = [];
612
+ let at = 0;
613
+ for (let it = pd.flow._start; it; it = it.right) {
614
+ if (it.content instanceof Y.ContentFormat) {
615
+ if (!it.deleted) { if (it.content.value === null) formats.delete(it.content.key); else formats.set(it.content.key, it.content.value); }
616
+ continue;
617
+ }
618
+ if (it.deleted || !it.countable) continue;
619
+ for (let offset = 0; offset < it.length; offset++) {
620
+ const id = { client: it.id.client, clock: it.id.clock + offset };
621
+ if (live.has(idKey(id))) units.push({ at: at + offset, ch: it.content instanceof Y.ContentString ? it.content.str[offset] : undefined, embed: it.content.embed, attrs: Object.fromEntries(formats), source: origins.resolve(id) });
622
+ }
623
+ at += it.length;
624
+ }
625
+ for (let i = units.length - 1; i >= 0; i--) pd.flow.delete(units[i].at, 1);
626
+ let cursor = 0;
627
+ for (let it = pd.flow._start; it && it !== edge; it = it.right) if (!it.deleted && it.countable) cursor += it.length;
628
+ const start = cursor;
629
+ const before = Y.getState(pd.ydoc.store, self);
630
+ const fences = [...walked.fences, edge.id];
631
+ for (const unit of units) {
632
+ if (unit.ch === undefined) beforeFence(pd, cursor, fences, () => pd.flow.insertEmbed(cursor, { ...unit.embed }, { ...unit.attrs }));
633
+ else beforeFence(pd, cursor, fences, () => pd.flow.insert(cursor, unit.ch, { ...unit.attrs }));
634
+ cursor += 1;
635
+ }
636
+ const dest = unitIds(pd.flow, start, cursor);
637
+ if (dest.length !== units.length) throw new Error(`Moved ${dest.length} carried units instead of ${units.length}`);
638
+ origins.recordSpans(dest.map((id, i) => ({ dest: id, source: units[i].source })));
639
+ if (group.step) group.step.insertions = Y.mergeDeleteSets([group.step.insertions, rangesOf([{ client: self, clock: before, len: Y.getState(pd.ydoc.store, self) - before }])]);
640
+ kept.push({ ids: dest, step: group.step });
641
+ }
642
+ }, CARRY);
643
+ return kept;
644
+ };
645
+
646
+ // Whether Yjs will change anything when it pops this step. It pops every step that would not,
647
+ // in the same press, so each of those needs its moved words followed too.
648
+ const acts = (pd, step) => {
649
+ const store = pd.ydoc.store;
650
+ for (const [client, ranges] of step.insertions.clients) for (const { clock, len } of ranges) {
651
+ for (let at = clock; at < clock + len;) {
652
+ let it;
653
+ try { it = Y.getItem(store, { client, clock: at }); } catch { break; }
654
+ let live = it;
655
+ while (live instanceof Y.Item && live.redone) { try { live = Y.getItem(store, live.redone); } catch { break; } }
656
+ if (live instanceof Y.Item && !live.deleted) return true;
657
+ at = it.id.clock + it.length;
658
+ }
659
+ }
660
+ for (const [client, ranges] of step.deletions.clients) for (const { clock } of ranges) {
661
+ if (!Y.isDeleted(step.insertions, { client, clock })) return true;
662
+ }
663
+ return false;
664
+ };
665
+
666
+ const liveIn = (pd, sourceKey) => {
667
+ const live = new Set();
668
+ for (let it = startOf(pd, sourceKey)?.right ?? null; it; it = it.right) {
669
+ if (!it.countable || it.deleted) continue;
670
+ if (markerOf(it)) break;
671
+ for (let offset = 0; offset < it.length; offset++) live.add(idKey({ client: it.id.client, clock: it.id.clock + offset }));
672
+ }
673
+ return live;
674
+ };
675
+
676
+ // Only the tab whose own Undo retired a copy carries that copy's words: a word's home copy is
677
+ // fixed (homeOf), so no two tabs carry the same word. A Redo carries nothing forward and
678
+ // mirrors only the recorded loss of a unit alive at the Undo (`alive`) or newer (`seen`).
679
+ export function createMoveCarry({ pd, origins, um, walked }) {
680
+ const owned = new Map();
681
+ const shown = new Map();
682
+ const homes = new WeakMap();
683
+ let flip = null;
684
+ let placed = [];
685
+ const run = (flipped = null, records = false) => owned.size > 0 && carryAcross({ pd, origins, walked: walked(), placed }, owned, homes, flipped, records);
686
+ return {
687
+ note(t) {
688
+ const keys = [...(t.changed.get(pd.gone) ?? [])].filter((key) => typeof key === "string");
689
+ if (!keys.some((key) => key.startsWith(COPY_ACTIVE_KEYS))) return;
690
+ const hidden = new Set();
691
+ const past = rangesOf([...t.deleteSet.clients].flatMap(([client, ranges]) => ranges.map(({ clock, len }) => ({ client, clock, len }))));
692
+ for (const key of keys) {
693
+ const copy = key.startsWith(COPY_ACTIVE_KEYS);
694
+ if (!copy && !key.startsWith(REMOVE_KEYS)) continue;
695
+ if (pd.gone.has(key) === copy) {
696
+ owned.delete(key);
697
+ if (!copy) shown.set(key, { seen: t.afterState, alive: liveIn(pd, JSON.parse(key)[1]) });
698
+ continue;
699
+ }
700
+ hidden.add(key);
701
+ owned.set(key, { copy, left: new Set(), past, seen: null, alive: null, step: null, ...(copy ? null : shown.get(key)) });
702
+ }
703
+ if (hidden.size) flip = hidden;
704
+ },
705
+ replay(direction) {
706
+ const pending = direction === "undo" ? um.undoStack : um.redoStack;
707
+ for (let i = pending.length - 1; i >= 0; i--) {
708
+ followStep(pd, origins, pending[i]);
709
+ if (acts(pd, pending[i])) break;
710
+ }
711
+ const inverse = direction === "undo" ? um.redoStack : um.undoStack;
712
+ const size = inverse.length;
713
+ flip = null;
714
+ if (um[direction]() === null) return false;
715
+ const flipped = flip;
716
+ flip = null;
717
+ if (flipped && inverse.length > size) for (const key of flipped) owned.get(key).step = inverse[inverse.length - 1];
718
+ run(flipped);
719
+ return true;
720
+ },
721
+ received(transactions) {
722
+ if (!owned.size && !placed.length) return;
723
+ let late = false, records = false;
724
+ for (const t of transactions) {
725
+ if (t.local) continue;
726
+ late = true;
727
+ for (const key of t.changed.get(pd.gone) ?? []) if (typeof key === "string" && key.startsWith(TEXT_DELETE_KEYS)) records = true;
728
+ }
729
+ if (!late) return;
730
+ run(null, records);
731
+ if (placed.length) placed = reseat(pd, origins, walked(), placed);
732
+ },
733
+ };
734
+ }
@@ -288,7 +288,7 @@ export function unitIds(flow, from, to) {
288
288
  return out;
289
289
  }
290
290
 
291
- const coversOriginal = (records, self, id) => records.some((record) => {
291
+ export const coversOriginal = (records, self, id) => records.some((record) => {
292
292
  if (record.actor === self || record.client !== id.client) return false;
293
293
  if (id.clock < record.clock || id.clock >= record.clock + record.length) return false;
294
294
  return record.sv.some(([client, clock]) => client === id.client && clock > id.clock);
@@ -345,7 +345,7 @@ const holdsClock = (spans, clock) => {
345
345
  return false;
346
346
  };
347
347
 
348
- const visibleOriginIndex = (flow, walked, index) => {
348
+ export const visibleOriginIndex = (flow, walked, index) => {
349
349
  const ranges = createProjectionMap(walked, flow.length).physicalRanges(0, walked.tokens);
350
350
  const clocks = new Map();
351
351
  const collect = (item, from, to) => {
@@ -296,10 +296,23 @@ function listen(inst, type, handler) {
296
296
  inst.listeners.push([type, handler]);
297
297
  }
298
298
 
299
+ // On a page that loads `undo` there is one history and it is the page's: every route the
300
+ // editor offers walks it, and the editor's own stack only says where its steps sit on it.
301
+ const paged = () => window.clay?.undo?.pageHistory === true;
302
+
299
303
  // The page's own history, the one the editor's Cmd+Z already uses: nothing is undoable
300
304
  // while a proposal is only a preview, because Discard is the whole of that change and
301
305
  // Keep is the one step worth undoing.
302
306
  function pageHistory(inst) {
307
+ if (paged()) {
308
+ const history = window.clay.undo;
309
+ return {
310
+ undo: () => history.undo(),
311
+ redo: () => history.redo(),
312
+ get canUndo() { return !!history.canUndo; },
313
+ get canRedo() { return !!history.canRedo; },
314
+ };
315
+ }
303
316
  const binding = inst?.binding;
304
317
  const um = binding?.bridge?.um;
305
318
  if (!binding || !um) return null;
@@ -327,6 +340,9 @@ function pageHistory(inst) {
327
340
  // One event for whatever presents the history, the same one plugins/undo.js emits: after
328
341
  // every entry the page's stack takes or replays, and whenever a preview comes up or goes.
329
342
  function notifyHistory(inst) {
343
+ // The undo plugin announces the one page history itself; a second event from here would
344
+ // say the editor's own stack decides what is undoable.
345
+ if (paged()) return;
330
346
  const history = pageHistory(inst);
331
347
  document.dispatchEvent(new CustomEvent("clay:undo-change", {
332
348
  detail: { canUndo: !!history?.canUndo, canRedo: !!history?.canRedo },
@@ -985,7 +1001,9 @@ export function createSteps(inst, options) {
985
1001
  install: async (id, mod) => {
986
1002
  if (!editorView) await editorReady;
987
1003
  if (!isCurrent(inst) || !editorView) return null;
988
- const context = { view: editorView, upload: isDraftMode ? draftUpload : options.upload, board: binding.board, undo: binding.undo, redo: binding.redo, revise: aiRequested() ? (toolId) => { openAi({ target: { toolId } }); } : null };
1004
+ const undo = () => (paged() ? window.clay.undo.undo() : binding.undo());
1005
+ const redo = () => (paged() ? window.clay.undo.redo() : binding.redo());
1006
+ const context = { view: editorView, upload: isDraftMode ? draftUpload : options.upload, board: binding.board, undo, redo, revise: aiRequested() ? (toolId) => { openAi({ target: { toolId } }); } : null };
989
1007
  if (id === "tool") {
990
1008
  context.toolHost = await openTools();
991
1009
  if (!isCurrent(inst)) return null;
@@ -1200,8 +1218,8 @@ function mountAndAttach(inst, source, loaded) {
1200
1218
  doc: binding.doc(),
1201
1219
  dispatch: binding.dispatch,
1202
1220
  history: false,
1203
- undo: binding.undo,
1204
- redo: binding.redo,
1221
+ undo: () => (paged() ? window.clay.undo.undo() : binding.undo()),
1222
+ redo: () => (paged() ? window.clay.undo.redo() : binding.redo()),
1205
1223
  board: binding.board,
1206
1224
  editable: false,
1207
1225
  features,
@@ -1263,10 +1281,23 @@ function mountAndAttach(inst, source, loaded) {
1263
1281
  }
1264
1282
  loaded.refreshView?.(view);
1265
1283
  binding.attach(view);
1284
+ // Every mounted editor hands its own steps to the page's history through this adapter,
1285
+ // made here whether or not a session is live yet: the adapter reads the history per
1286
+ // step and follows the session as it starts, stops and starts again.
1287
+ const um = binding.bridge?.um;
1288
+ if (um) {
1289
+ const bridge = pageCode.createHistoryBridge({
1290
+ um,
1291
+ undo: () => binding.undo(),
1292
+ redo: () => binding.redo(),
1293
+ article,
1294
+ focus: () => inst.view?.focus?.(),
1295
+ });
1296
+ addDisposer(inst, bridge.dispose);
1297
+ }
1266
1298
  // The page's history tells whoever presents it once it exists (a menu opened before the
1267
1299
  // editor came up gets its Undo row then), and once per entry it takes or replays. A
1268
1300
  // peer's typing takes none of them: their work is not this tab's to undo.
1269
- const um = binding.bridge?.um;
1270
1301
  if (um) {
1271
1302
  const notify = () => notifyHistory(inst);
1272
1303
  um.on("stack-item-added", notify);
@@ -1287,9 +1318,22 @@ function mountAndAttach(inst, source, loaded) {
1287
1318
  const previewIs = previewShown(next.state);
1288
1319
  if (previewIs === previewWas) return;
1289
1320
  previewWas = previewIs;
1321
+ // A proposal on screen owns the moment: the page's history is held, so Cmd+Z can
1322
+ // undo nothing behind it, and released when the proposal goes.
1323
+ if (paged()) {
1324
+ if (previewIs) inst.releaseHold = window.clay.undo.hold();
1325
+ else {
1326
+ inst.releaseHold?.();
1327
+ inst.releaseHold = null;
1328
+ }
1329
+ }
1290
1330
  notifyHistory(inst);
1291
1331
  },
1292
1332
  }));
1333
+ addDisposer(inst, () => {
1334
+ inst.releaseHold?.();
1335
+ inst.releaseHold = null;
1336
+ });
1293
1337
  installAwareness(inst, view);
1294
1338
  editorReadyResolve();
1295
1339
  // The parts the saved article demands install onto the mounted view now, whether