@panphora/clayjs 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) 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 +7917 -5621
  11. package/dist/m/attrs/refetch-on-save-ZUE7PSCI.js +1 -0
  12. package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
  13. package/dist/m/c-2YYDMLNZ.js +1 -0
  14. package/dist/m/c-336SGU5W.js +1 -0
  15. package/dist/m/c-3H6BZDNJ.js +6 -0
  16. package/dist/m/{c-LR4CGEPT.js → c-3RUU4TPX.js} +1 -1
  17. package/dist/m/c-3XIRD4NU.js +1 -0
  18. package/dist/m/c-4DFEJ2PO.js +3 -0
  19. package/dist/m/c-56QRZTZH.js +1 -0
  20. package/dist/m/c-5J4E7O4Y.js +4 -0
  21. package/dist/m/c-5LKDWQTL.js +19 -0
  22. package/dist/m/c-5QWQ7A5L.js +1 -0
  23. package/dist/m/c-5RJFTLSF.js +1 -0
  24. package/dist/m/c-6AZOGAPE.js +1 -0
  25. package/dist/m/{c-Y6USGTS5.js → c-6CRUUXOX.js} +5 -5
  26. package/dist/m/{c-54RCDCBU.js → c-6KEVEQ7L.js} +1 -1
  27. package/dist/m/{c-LTGMXXKO.js → c-6NC5I2TV.js} +1 -1
  28. package/dist/m/{c-YDH7Q3NY.js → c-74MGYWZF.js} +1 -1
  29. package/dist/m/{c-Z36J63PP.js → c-7OLLJPWP.js} +1 -1
  30. package/dist/m/c-A3E3NIMC.js +1 -0
  31. package/dist/m/{c-5R4PTRG2.js → c-AMSNPOCX.js} +1 -1
  32. package/dist/m/c-B3JM5ZXC.js +1 -0
  33. package/dist/m/c-BPF2NSPV.js +1 -0
  34. package/dist/m/c-BQ6LOBEE.js +1 -0
  35. package/dist/m/c-CJ3A7ZKE.js +1 -0
  36. package/dist/m/c-CQHPGKX2.js +1 -0
  37. package/dist/m/c-DCXOXBF3.js +1 -0
  38. package/dist/m/c-DFS4XZJV.js +2 -0
  39. package/dist/m/{c-F3CKJTJS.js → c-DNENWY4O.js} +1 -1
  40. package/dist/m/c-E4LIR5X5.js +1 -0
  41. package/dist/m/c-EVCQ3BKA.js +1 -0
  42. package/dist/m/{c-5Y6TNX5W.js → c-EZU56NQV.js} +2 -2
  43. package/dist/m/c-FDNHHZY5.js +1 -0
  44. package/dist/m/{c-SWRJFCNN.js → c-FRLHSY7E.js} +1 -1
  45. package/dist/m/c-FROJUZMV.js +1 -0
  46. package/dist/m/c-G2JPKCTP.js +1 -0
  47. package/dist/m/c-GB6TU7WV.js +1 -0
  48. package/dist/m/{c-GB3JTV6E.js → c-GFJJEBTU.js} +1 -1
  49. package/dist/m/{c-J7CA54IP.js → c-GSFIGKOP.js} +1 -1
  50. package/dist/m/{c-OYTFP5QN.js → c-GY422N3Q.js} +1 -1
  51. package/dist/m/c-HAWFNBYG.js +1 -0
  52. package/dist/m/{c-33HUYEDI.js → c-HDDEEL77.js} +1 -1
  53. package/dist/m/c-HFKUVACH.js +1 -0
  54. package/dist/m/c-HHDFCNHV.js +1 -0
  55. package/dist/m/c-I2ZATQCD.js +2 -0
  56. package/dist/m/c-IEM4NTCS.js +1 -0
  57. package/dist/m/c-IW6GGOU2.js +1 -0
  58. package/dist/m/{c-SGCCC4FW.js → c-JDCX26UP.js} +2 -2
  59. package/dist/m/c-JRKY44DK.js +1 -0
  60. package/dist/m/c-JU3NK25H.js +1 -0
  61. package/dist/m/{c-VFX45IZQ.js → c-KAU6E43D.js} +1 -1
  62. package/dist/m/c-KBS3UV6B.js +1 -0
  63. package/dist/m/c-KGVGROZT.js +1 -0
  64. package/dist/m/{c-YG2NJYS6.js → c-KQ6TYJAP.js} +2 -2
  65. package/dist/m/{c-2JL6E75N.js → c-KU3ZFADW.js} +1 -1
  66. package/dist/m/c-LWLBHLVC.js +3 -0
  67. package/dist/m/c-M2L47S2X.js +7 -0
  68. package/dist/m/c-MFP2ZWD4.js +1 -0
  69. package/dist/m/{c-4YSCRICO.js → c-MKSQ4Y57.js} +1 -1
  70. package/dist/m/{c-2M73252O.js → c-NUFFJVVJ.js} +1 -1
  71. package/dist/m/c-NUHSPT5P.js +1 -0
  72. package/dist/m/c-O72HVYSA.js +1 -0
  73. package/dist/m/c-OW5QIGG3.js +1 -0
  74. package/dist/m/c-PGRCK73F.js +1 -0
  75. package/dist/m/{c-4DRMS4QG.js → c-PS5ARVWR.js} +1 -1
  76. package/dist/m/c-Q622NS2Y.js +1 -0
  77. package/dist/m/c-Q7NQD65V.js +1 -0
  78. package/dist/m/c-QH63LQOS.js +1 -0
  79. package/dist/m/{c-TQZ2JYHD.js → c-QICP2YR5.js} +1 -1
  80. package/dist/m/{c-7VGZ7RU5.js → c-QJ4OM3G6.js} +1 -1
  81. package/dist/m/{c-MMUIUUZG.js → c-R3L7ZJGB.js} +1 -1
  82. package/dist/m/c-R7IWVINQ.js +1 -0
  83. package/dist/m/{c-HVYXIS72.js → c-RSMOODDU.js} +1 -1
  84. package/dist/m/{c-KKWLDOPM.js → c-RYUXUUBX.js} +1 -1
  85. package/dist/m/c-S4ZEH46B.js +9 -0
  86. package/dist/m/c-SDAXDM2D.js +1 -0
  87. package/dist/m/c-SMBZG5X4.js +1 -0
  88. package/dist/m/{c-UXBMRDZB.js → c-SMGU7RT3.js} +1 -1
  89. package/dist/m/c-SPIORRYS.js +1 -0
  90. package/dist/m/c-SWLRT66R.js +1 -0
  91. package/dist/m/c-SZGYW5HT.js +1 -0
  92. package/dist/m/c-TCJTVAFN.js +1 -0
  93. package/dist/m/{c-HRDCD3WN.js → c-TJ6WOJL3.js} +1 -1
  94. package/dist/m/c-TYLXX4NF.js +21 -0
  95. package/dist/m/c-U62OB4BT.js +1 -0
  96. package/dist/m/{c-NMJRXKPC.js → c-U6YWUAR3.js} +1 -1
  97. package/dist/m/c-UDCSU5G5.js +2 -0
  98. package/dist/m/{c-NZMNXVF6.js → c-UDUUJPFZ.js} +1 -1
  99. package/dist/m/c-VE6ZMYMC.js +1 -0
  100. package/dist/m/c-VG5XIN4T.js +1 -0
  101. package/dist/m/c-VHM5XQHK.js +1 -0
  102. package/dist/m/c-VI5RMSVY.js +1 -0
  103. package/dist/m/c-VRZN56XY.js +1 -0
  104. package/dist/m/c-W5MCHEGA.js +1 -0
  105. package/dist/m/c-WDC5GVKU.js +1 -0
  106. package/dist/m/c-WWXIDH4O.js +28 -0
  107. package/dist/m/{c-QIX5BJYN.js → c-XCNTK76U.js} +1 -1
  108. package/dist/m/c-XMS6DPBR.js +1 -0
  109. package/dist/m/c-Y5HBBIQ4.js +1 -0
  110. package/dist/m/{c-5M4YEKCX.js → c-ZVPVXTJF.js} +1 -1
  111. package/dist/m/c-ZVRKXKM7.js +1 -0
  112. package/dist/m/{c-HT33EQRN.js → c-ZWIOJGMT.js} +1 -1
  113. package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
  114. package/dist/m/core/autosave-HRYGUWV6.js +1 -0
  115. package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
  116. package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
  117. package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
  118. package/dist/m/core/{persist-HKHA66IY.js → persist-JFYRHMRJ.js} +1 -1
  119. package/dist/m/core/recovery-FLTCC2OC.js +1 -0
  120. package/dist/m/core/save-HQZ4S667.js +1 -0
  121. package/dist/m/core/save-core-KGQWFUNH.js +1 -0
  122. package/dist/m/core/snapshot-EGQZNT45.js +1 -0
  123. package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
  124. package/dist/m/events/index-RN2JNGBV.js +1 -0
  125. package/dist/m/internals/index-PVXGPUDA.js +1 -0
  126. package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
  127. package/dist/m/loader-S4I7GKXR.js +1 -0
  128. package/dist/m/manifest-4C419D67.js +1 -0
  129. package/dist/m/manifest-edit-B077F288.js +1 -0
  130. package/dist/m/manifest.json +1555 -1058
  131. package/dist/m/options/options-EJMS3K24.js +1 -0
  132. package/dist/m/page/{editor-XUTB2HB2.js → editor-NNJA4OKD.js} +21 -8
  133. package/dist/m/plugins/{ai-edit-ZQ4CGWIP.js → ai-edit-QN4PMMNL.js} +1 -1
  134. package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
  135. package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
  136. package/dist/m/plugins/live-HFWD4OIH.js +1 -0
  137. package/dist/m/plugins/{menu-HCKO3DSP.js → menu-3HIEQSI6.js} +2 -2
  138. package/dist/m/plugins/page-JOKB6XPR.js +2 -0
  139. package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
  140. package/dist/m/plugins/people-SGPJKANK.js +1 -0
  141. package/dist/m/plugins/{richclay-RC3L2F6X.js → richclay-NRQDSWX6.js} +2 -2
  142. package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
  143. package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
  144. package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
  145. package/dist/m/plugins/{upgrade-ZNYODP5Y.js → upgrade-W7FG5VFG.js} +1 -1
  146. package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
  147. package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
  148. package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
  149. package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
  150. package/dist/m/{reader-core-ILWLXVMP.js → reader-core-DXYCURFF.js} +2 -2
  151. package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
  152. package/dist/m/ui/{index-EVHKMKBL.js → index-3JLVI27O.js} +1 -1
  153. package/dist/offline-manifest.json +857 -623
  154. package/dist/page/themes/light-1.css +25 -1
  155. package/dist/server/core.cjs +115 -24
  156. package/package.json +1 -1
  157. package/src/core/boot-phase.js +2 -1
  158. package/src/core/conflict-download.js +3 -29
  159. package/src/core/download.js +32 -0
  160. package/src/core/host-meta.js +18 -0
  161. package/src/core/save-core.js +8 -4
  162. package/src/core/save.js +4 -4
  163. package/src/core/snapshot.js +39 -1
  164. package/src/lib/autosave-scheduler.js +3 -4
  165. package/src/page/blocks.js +34 -1
  166. package/src/page/board-views.js +2 -16
  167. package/src/page/chart-tables.js +11 -0
  168. package/src/page/chart.js +6 -10
  169. package/src/page/checkpoint-proof.js +289 -0
  170. package/src/page/chrome.js +24 -6
  171. package/src/page/document-title-dom.js +12 -0
  172. package/src/page/document-title-runtime.js +15 -1
  173. package/src/page/document-title.js +4 -1
  174. package/src/page/editor.js +77 -12
  175. package/src/page/emoji-data.js +162 -0
  176. package/src/page/features/board.js +12 -3
  177. package/src/page/features/chart.js +11 -0
  178. package/src/page/features/dates.js +180 -0
  179. package/src/page/features/emoji.js +187 -0
  180. package/src/page/features/mentions.js +188 -0
  181. package/src/page/features/registry.js +6 -1
  182. package/src/page/features/table.js +2 -2
  183. package/src/page/format-command.js +9 -0
  184. package/src/page/format-host.js +54 -0
  185. package/src/page/format-native-commands.js +142 -0
  186. package/src/page/format-native.js +42 -0
  187. package/src/page/inline.js +32 -8
  188. package/src/page/items.js +15 -3
  189. package/src/page/markdown.js +26 -2
  190. package/src/page/mention-picker.js +99 -0
  191. package/src/page/merge.js +1 -1
  192. package/src/page/projection.js +58 -15
  193. package/src/page/save-projection.js +39 -0
  194. package/src/page/save-states.js +1 -1
  195. package/src/page/schema.js +33 -0
  196. package/src/page/themes/content.css +25 -1
  197. package/src/page/themes/light-1.css +25 -1
  198. package/src/page/view-hooks.js +32 -0
  199. package/src/page/ybind/binding.js +31 -4
  200. package/src/page/ybind/marks.js +2 -1
  201. package/src/page/ybind/provider.js +13 -2
  202. package/src/page/ybind/recovery-replay.js +362 -4
  203. package/src/plugins/live.js +37 -1
  204. package/src/plugins/page.js +117 -3
  205. package/src/plugins/visitor-drafts/intro.js +2 -1
  206. package/src/server/checkpoint/index.js +2 -2
  207. package/src/sync/saved-stream.js +17 -8
  208. package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +0 -1
  209. package/dist/m/attrs/save-freeze-FUP4Q4X3.js +0 -1
  210. package/dist/m/c-35NBGMZB.js +0 -1
  211. package/dist/m/c-3IQRECYS.js +0 -1
  212. package/dist/m/c-4B6H4G5V.js +0 -1
  213. package/dist/m/c-4FFHZM32.js +0 -1
  214. package/dist/m/c-66O32MV3.js +0 -28
  215. package/dist/m/c-7O6AFR65.js +0 -1
  216. package/dist/m/c-ANQTJ3B5.js +0 -2
  217. package/dist/m/c-AYVOREBE.js +0 -1
  218. package/dist/m/c-B2ZXPOS3.js +0 -3
  219. package/dist/m/c-BMIZKMPV.js +0 -1
  220. package/dist/m/c-CJZSLMVQ.js +0 -1
  221. package/dist/m/c-DBE6ORSI.js +0 -1
  222. package/dist/m/c-EDHUR4MX.js +0 -1
  223. package/dist/m/c-EY6UOM54.js +0 -1
  224. package/dist/m/c-F7Y6FLPY.js +0 -1
  225. package/dist/m/c-FA4DZVTC.js +0 -1
  226. package/dist/m/c-G2BUZITR.js +0 -1
  227. package/dist/m/c-GBGWXTX4.js +0 -1
  228. package/dist/m/c-GHBCCVG4.js +0 -1
  229. package/dist/m/c-IB52R237.js +0 -1
  230. package/dist/m/c-IKWNE4R4.js +0 -1
  231. package/dist/m/c-IU3IYEU6.js +0 -1
  232. package/dist/m/c-IZ2EBRAJ.js +0 -1
  233. package/dist/m/c-KR4UW4KW.js +0 -1
  234. package/dist/m/c-KY3VJGUA.js +0 -8
  235. package/dist/m/c-LXENEBV7.js +0 -1
  236. package/dist/m/c-PPOSCAQD.js +0 -1
  237. package/dist/m/c-QQHEX6HA.js +0 -1
  238. package/dist/m/c-R7RMANJI.js +0 -9
  239. package/dist/m/c-RI4NT75Y.js +0 -1
  240. package/dist/m/c-RUJKSUQW.js +0 -1
  241. package/dist/m/c-T4VO4OCB.js +0 -1
  242. package/dist/m/c-TR6RMDQ7.js +0 -3
  243. package/dist/m/c-UKHTLWUW.js +0 -1
  244. package/dist/m/c-UNN7LWCT.js +0 -1
  245. package/dist/m/c-V7PZZRG7.js +0 -1
  246. package/dist/m/c-VL52ADDR.js +0 -1
  247. package/dist/m/c-WWDBYXLK.js +0 -1
  248. package/dist/m/c-XNNW2CWK.js +0 -1
  249. package/dist/m/c-Z4VRC7GU.js +0 -7
  250. package/dist/m/c-Z6K52OLP.js +0 -19
  251. package/dist/m/c-ZDY2MDAA.js +0 -21
  252. package/dist/m/core/admin-attrs-VGNLAUDK.js +0 -1
  253. package/dist/m/core/autosave-E4PEFPZ6.js +0 -1
  254. package/dist/m/core/conflict-notice-VQ6Y64PH.js +0 -1
  255. package/dist/m/core/host-permission-XN3K2IW3.js +0 -1
  256. package/dist/m/core/is-edit-mode-JXHDVGUL.js +0 -1
  257. package/dist/m/core/recovery-TBBSICE2.js +0 -1
  258. package/dist/m/core/save-WUCHTQBE.js +0 -1
  259. package/dist/m/core/save-core-FJVHT5M3.js +0 -1
  260. package/dist/m/core/snapshot-F3SWDZMM.js +0 -1
  261. package/dist/m/core/unsaved-warning-STKBHE6G.js +0 -1
  262. package/dist/m/events/index-23DPTUT4.js +0 -1
  263. package/dist/m/internals/index-WOVAP3RU.js +0 -1
  264. package/dist/m/lib/cache-bust-CGWT6ZJI.js +0 -1
  265. package/dist/m/loader-HXX6YNH5.js +0 -1
  266. package/dist/m/manifest-F22DEDAC.js +0 -1
  267. package/dist/m/manifest-edit-C3C7376B.js +0 -1
  268. package/dist/m/options/options-2LH76JQ3.js +0 -1
  269. package/dist/m/plugins/demo-ZIBFAA3R.js +0 -1
  270. package/dist/m/plugins/indicator-PV4YW2ID.js +0 -1
  271. package/dist/m/plugins/live-NUTTXSBS.js +0 -1
  272. package/dist/m/plugins/page-KSUH7G6J.js +0 -2
  273. package/dist/m/plugins/page-view-W73OW7WI.js +0 -1
  274. package/dist/m/plugins/people-AJJ32YCG.js +0 -1
  275. package/dist/m/plugins/sortable-YRMQX3ZU.js +0 -1
  276. package/dist/m/plugins/source-KHK5UUSK.js +0 -1
  277. package/dist/m/plugins/undo-OF2L3CXU.js +0 -1
  278. package/dist/m/plugins/upload-XSGNILUY.js +0 -1
  279. package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +0 -21
  280. package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +0 -1
  281. package/dist/m/plugins/wire-ZWVWKF76.js +0 -1
  282. package/dist/m/sync/live-sync-4AUVXVJU.js +0 -1
  283. /package/dist/m/plugins/{richclay-RC3L2F6X.js.LEGAL.txt → richclay-NRQDSWX6.js.LEGAL.txt} +0 -0
@@ -8,6 +8,7 @@ import { REMOTE, newId } from "./model.js";
8
8
  import { safeHref, safeSrc } from "./link.js";
9
9
  import { flatten, runsOf, unflatten, unitsOf } from "./inline.js";
10
10
  import { OPAQUE_MARK, opaqueMark } from "./format-codec.js";
11
+ import { NATIVE_NAMES, nativeAttrs } from "./format-native.js";
11
12
  import { DEFAULTS, MARKS, OPAQUE, envelopeAttrs, envelopeRecord, marksAttrs, marksEntries } from "./projection.js";
12
13
 
13
14
  const alignOf = (a) => (a === "left" || a === "right" ? a : null);
@@ -1118,6 +1119,10 @@ export const clipboardParser = new model.DOMParser(parseSchema, [
1118
1119
  { tag: "del", mark: "strike" },
1119
1120
  { tag: "code", mark: "code" },
1120
1121
  { tag: "a[href]", mark: "link", getAttrs: (d) => { const href = safeHref(d.getAttribute("href")); return href ? { href } : false; } },
1122
+ { tag: "blockquote", node: "quote", getAttrs: (d) => ({ parent: parentAttr(d) }) },
1123
+ { tag: "u", mark: "underline" },
1124
+ { tag: "mark", mark: "highlight", getAttrs: (d) => nativeAttrs("highlight", { color: d.getAttribute("data-color") }) ?? false },
1125
+ { tag: "span[data-color]", mark: "text_color", getAttrs: (d) => nativeAttrs("text_color", { color: d.getAttribute("data-color") }) ?? false },
1121
1126
  ]);
1122
1127
 
1123
1128
  // Every pasted block arrives with no id and no pos (identityPlugin mints and
@@ -1202,10 +1207,38 @@ function pasteIntoClosedSummary(view, _event, slice) {
1202
1207
  return true;
1203
1208
  }
1204
1209
 
1210
+ // The four formats pasted in from elsewhere are floor-1 content too: a slice
1211
+ // that holds one asks the gate before it is inserted, and text waits in the
1212
+ // clipboard rather than being dropped when the answer is a refusal.
1213
+ function writesFormat(slice) {
1214
+ let found = false;
1215
+ slice?.content?.descendants((node) => {
1216
+ if (found) return false;
1217
+ if (node.type === N.quote) found = true;
1218
+ else if (node.isText && node.marks.some((mark) => NATIVE_NAMES.has(mark.type.name))) found = true;
1219
+ return !found;
1220
+ });
1221
+ return found;
1222
+ }
1223
+
1224
+ function pasteFormat(view, event, slice) {
1225
+ const from = view.state.selection.from, to = view.state.selection.to;
1226
+ const plain = () => {
1227
+ const text = slice.content.textBetween(0, slice.content.size, "\n", " ");
1228
+ if (!view.isDestroyed && text) view.dispatch(view.state.tr.insertText(text, from, to));
1229
+ return false;
1230
+ };
1231
+ import("./format-native-commands.js").then(({ pasteFormats }) => pasteFormats(view, slice, from, to), plain);
1232
+ return true;
1233
+ }
1234
+
1205
1235
  export function pastePlugin() {
1206
1236
  return new Plugin({
1207
1237
  props: {
1208
- handlePaste: pasteIntoClosedSummary,
1238
+ handlePaste(view, event, slice) {
1239
+ if (writesFormat(slice)) return pasteFormat(view, event, slice);
1240
+ return pasteIntoClosedSummary(view, event, slice);
1241
+ },
1209
1242
  clipboardSerializer,
1210
1243
  transformPastedHTML: (html) => flattenPastedHTML(html),
1211
1244
  clipboardParser,
@@ -1,17 +1,3 @@
1
- import { state } from "../vendor/prosemirror.vendor.js";
2
-
3
- // The card panels a board plugin can reach, filled by the board view part when it
4
- // loads. The plugin works, and does nothing, for a board without a panel.
1
+ // The card panels a board part can reach, filled by the board view part when it
2
+ // loads. A board without a panel does nothing on every change.
5
3
  export const cardPanels = new WeakMap();
6
-
7
- export function boardPlugin() {
8
- return new state.Plugin({
9
- view: (view) => ({
10
- update: () => cardPanels.get(view)?.sync(),
11
- destroy: () => {
12
- cardPanels.get(view)?.hide(false, false);
13
- cardPanels.delete(view);
14
- },
15
- }),
16
- });
17
- }
@@ -0,0 +1,11 @@
1
+ import { schema } from "./schema.js";
2
+ import { orderedIds } from "./model.js";
3
+
4
+ // The tables a chart can be drawn from: a top-level table with at least one
5
+ // column. The insert menu reads this without the chart module itself, which only
6
+ // a document that takes a chart loads.
7
+ export function chartTables(doc) {
8
+ const tables = [];
9
+ doc.forEach((node) => { if (node.type === schema.nodes.table && orderedIds(node.attrs.cols).length) tables.push(node); });
10
+ return tables;
11
+ }
package/src/page/chart.js CHANGED
@@ -1,6 +1,6 @@
1
- import { state } from "../vendor/prosemirror.vendor.js";
2
1
  import { schema } from "./schema.js";
3
2
  import { blockJson, keyBetween, newId, orderedIds } from "./model.js";
3
+ import { chartTables } from "./chart-tables.js";
4
4
  import { chartModel, renderChartSvg } from "./rich/chart.js";
5
5
  import { openPageMenu } from "./chrome.js";
6
6
  import { project } from "./projection.js";
@@ -50,11 +50,7 @@ export function insertChartAfterTable(view, tablePos) {
50
50
  return true;
51
51
  }
52
52
 
53
- export function chartTables(doc) {
54
- const tables = [];
55
- doc.forEach((node) => { if (node.type === schema.nodes.table && orderedIds(node.attrs.cols).length) tables.push(node); });
56
- return tables;
57
- }
53
+ export { chartTables };
58
54
 
59
55
  // /chart: an empty line becomes the chart, otherwise it goes below the line.
60
56
  // One table is charted at once; several open a picker named by their columns.
@@ -213,8 +209,8 @@ export class ChartView {
213
209
  }
214
210
  }
215
211
 
216
- export function chartPlugin() {
217
- return new state.Plugin({
218
- view: () => ({ update(view) { for (const chart of views.get(view) ?? []) chart.refresh(); } }),
219
- });
212
+ // The hook the chart view part registers for its editor view: every change can
213
+ // have moved a chart's source table.
214
+ export function refreshCharts(view) {
215
+ for (const chart of views.get(view) ?? []) chart.refresh();
220
216
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * checkpoint-proof.js — the proof one accepted save produces, and the conditional read
3
+ * that answers for bytes this tab did not write.
4
+ *
5
+ * Step 2 built the proof itself: the recorded revisions, the accepted page state, the
6
+ * final article and the receipt, checked by `validatePageCheckpoint` before a store
7
+ * retires anything. What it left here is the producer's half. A proof is made at the
8
+ * accepted-save event, from the page state pinned to THAT capture, never from the live
9
+ * store: an edit made after the capture is in the store and is not in the bytes the host
10
+ * took, and reading the store when the response lands would cover it and clear it.
11
+ *
12
+ * Checklist item 2 lives here too. A state that no longer projects to the article that
13
+ * went to disk proves nothing about those bytes, so an authored transform that changed
14
+ * meaning, a page rebuilt from another article or a stale tag all produce no proof, and
15
+ * the records stay.
16
+ *
17
+ * The second half is the conditional page-state client: the host's own copy of the page
18
+ * state, asked for by the accepted ETag, for the cases a local capture cannot answer —
19
+ * the server's checkpoint, another writer's, and a lost reply that only a verified
20
+ * refetch can reconcile. Nothing here reads a cookie, a room sequence or a version
21
+ * string: a 412, a 428 or a 404 keeps every record, and so does any state whose
22
+ * identity, generation, digest or projection disagrees with what was accepted.
23
+ *
24
+ * It loads only when a recovery channel is registered: nothing else imports it.
25
+ */
26
+
27
+ import { saveToken } from "../core/host-attrs.js";
28
+ import { setCapturePageState } from "../core/snapshot.js";
29
+ import { canonicalPageBytes, digestHex, pageProof, validatePageCheckpoint } from "./ybind/recovery-proof.js";
30
+ import { articleOfPageState, pageStateOfClone } from "./save-projection.js";
31
+
32
+ // The capture half. A save capture carries its page state only once something can prove
33
+ // it, so the reader is installed here rather than from the page bundle: a page that
34
+ // journals nothing never pays for it, and a page that does gets the state of the exact
35
+ // clone whose bytes it sends, after every transform.
36
+ setCapturePageState(pageStateOfClone);
37
+
38
+ /** The only page-state resource version this client reads. */
39
+ export const PAGE_STATE_VERSION = 1;
40
+
41
+ export class PageCheckpointError extends Error {
42
+ constructor(code, message) {
43
+ super(message);
44
+ this.name = "PageCheckpointError";
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ const fail = (code, message) => { throw new PageCheckpointError(code, message); };
50
+ const refused = (code, message) => ({ ok: false, code, message });
51
+ const HEX64 = /^[0-9a-f]{64}$/;
52
+ const GENERATION = /^[0-9a-f]{32}$/;
53
+
54
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
55
+
56
+ // The state as this tab holds it, copied: a pinned capture's value shares its `y` map
57
+ // with the store's at the moment of the copy and must not share it after.
58
+ function copyState(state) {
59
+ return { v: state.v, g: state.g, y: { ...(state.y ?? {}) } };
60
+ }
61
+
62
+ // The state the bytes are decoded from, strictly, and its canonical form.
63
+ function stateOfTag(tag) {
64
+ let state;
65
+ try { state = JSON.parse(tag); }
66
+ catch (_) { fail("invalid-state", "The page state is not JSON"); }
67
+ if (!isObject(state) || state.v !== 1) fail("invalid-state", "Expected a version 1 Yjs page value");
68
+ return { state: copyState(state), bytes: canonicalPageBytes(state) };
69
+ }
70
+
71
+ const idKey = (value) => (value === null || value === undefined ? "" : String(value));
72
+
73
+ /**
74
+ * Ask the host for the page state of one accepted version, conditionally.
75
+ *
76
+ * The ETag is the one the accepted save carried, so the answer is about those exact
77
+ * bytes or about nothing. 412 (another version is stored), 428 (no condition was sent,
78
+ * which no answer of ours can be), 404 (this host keeps no page state) and every other
79
+ * failure are refusals with a code: the caller keeps its records and asks again later.
80
+ *
81
+ * @param {Object} options
82
+ * @param {string} options.href - the resource discovery named in `document.pageState.href`
83
+ * @param {string} options.etag - the ETag the accepted bytes were stored under
84
+ * @param {Function} [options.fetch]
85
+ * @param {AbortSignal} [options.signal]
86
+ * @returns {Promise<{ok: true, version: number, documentId: *, generation: string, etag: string, stateHash: string, tag: string}|{ok: false, code: string, message: string}>}
87
+ */
88
+ export async function readPageState({ href, etag, fetch: doFetch = globalThis.fetch, signal = null } = {}) {
89
+ if (typeof etag !== "string" || !etag) return refused("missing-etag", "A conditional read needs the accepted ETag");
90
+ if (typeof href !== "string" || !href) return refused("no-page-state", "This host names no page-state resource");
91
+ if (typeof doFetch !== "function") return refused("unavailable", "This tab cannot read the page state");
92
+ const base = globalThis.location?.href ?? "http://localhost/";
93
+ let url;
94
+ try { url = new URL(href, base).href; }
95
+ catch (_) { return refused("no-page-state", "The page-state resource is not a URL"); }
96
+ const token = saveToken();
97
+ let res;
98
+ try {
99
+ res = await doFetch(url, {
100
+ method: "GET",
101
+ credentials: token ? "omit" : "same-origin",
102
+ headers: { "If-Match": etag, "Document-URL": globalThis.location?.href ?? base },
103
+ cache: "no-store",
104
+ redirect: "manual",
105
+ signal,
106
+ });
107
+ } catch (_) {
108
+ return refused("unavailable", "The page-state request did not complete");
109
+ }
110
+ if (!res || typeof res.status !== "number") return refused("unavailable", "The page-state request did not answer");
111
+ if (res.status === 412) return refused("etag-mismatch", "Another version of this document is stored");
112
+ if (res.status === 428) return refused("etag-required", "The host needs a condition this read cannot send");
113
+ if (res.status === 404) return refused("no-page-state", "This host keeps no page state");
114
+ if (!res.ok) return refused("unavailable", `The host answered ${res.status}`);
115
+
116
+ let body;
117
+ try { body = JSON.parse(await res.text()); }
118
+ catch (_) { return refused("invalid-state", "The page-state answer is not JSON"); }
119
+ if (!isObject(body) || body.version !== PAGE_STATE_VERSION) return refused("unsupported-version", "Unknown page-state version");
120
+ if (typeof body.tag !== "string" || !body.tag) return refused("invalid-state", "The page-state answer carries no page tag");
121
+ if (typeof body.stateHash !== "string" || !HEX64.test(body.stateHash)) return refused("invalid-state", "The page-state answer carries no state digest");
122
+ if (typeof body.generation !== "string" || !GENERATION.test(body.generation)) return refused("invalid-state", "The page-state answer carries no generation");
123
+ if (typeof body.etag !== "string" || !body.etag) return refused("invalid-state", "The page-state answer carries no ETag");
124
+ if (!Object.hasOwn(body, "documentId")) return refused("invalid-state", "The page-state answer carries no document identity");
125
+ return {
126
+ ok: true,
127
+ version: body.version,
128
+ documentId: body.documentId,
129
+ generation: body.generation,
130
+ etag: body.etag,
131
+ stateHash: body.stateHash,
132
+ tag: body.tag,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Whether a conditional read answers for the version this tab accepted.
138
+ *
139
+ * Identity, generation, ETag, digest and projection, in that order: a state for another
140
+ * document, another generation, another version of the same document or another article
141
+ * is refused rather than used, and the receipt names the pair the host persisted.
142
+ *
143
+ * @param {Object} read - an answer from readPageState()
144
+ * @param {Object} options
145
+ * @param {{authority: Object, lineage: Object}} options.accepted
146
+ * @param {string} options.article - the final outgoing canonical article
147
+ * @param {string} options.etag - the ETag the accepted save carried
148
+ * @returns {Promise<{ok: true, state: Object, receipt: {file: string, pageState: string}}|{ok: false, code: string, message: string}>}
149
+ */
150
+ export async function verifyPageState(read, { accepted, article, etag } = {}) {
151
+ if (!read || read.ok !== true) return refused(read?.code ?? "unavailable", read?.message ?? "The page state was not read");
152
+ if (idKey(read.documentId) !== idKey(accepted?.authority?.documentId)) return refused("foreign-document", "The page state belongs to another document");
153
+ if (typeof accepted?.lineage?.generation !== "string" || read.generation !== accepted.lineage.generation) return refused("foreign-generation", "The page state belongs to another generation");
154
+ if (read.etag !== etag) return refused("etag-mismatch", "The page state belongs to another version of this document");
155
+ let stateBytes, state;
156
+ try { ({ state, bytes: stateBytes } = stateOfTag(read.tag)); }
157
+ catch (error) { return refused(error?.code ?? "invalid-state", error?.message ?? "The page state does not decode"); }
158
+ if (await digestHex(stateBytes) !== read.stateHash) return refused("state-digest-mismatch", "The page state is not the one the host hashed");
159
+ if (typeof article !== "string") return refused("missing-article", "A proof names the final outgoing article");
160
+ let projected;
161
+ try { projected = articleOfPageState(state); }
162
+ catch (error) { return refused("invalid-state", error?.message ?? "The page state does not project"); }
163
+ if (projected !== article) return refused("article-mismatch", "The page state does not project to the accepted article");
164
+ return { ok: true, state, receipt: { file: read.etag, pageState: read.stateHash } };
165
+ }
166
+
167
+ // A proof is only worth the records the validator confirms, over the revisions the
168
+ // caller observed: a proof that covers nothing keeps everything.
169
+ async function covered(proof, records) {
170
+ const observed = (records || []).map(({ id, revision }) => ({ id, revision }));
171
+ try {
172
+ return { ok: true, proof, covered: await validatePageCheckpoint(proof, observed) };
173
+ } catch (error) {
174
+ return refused(error?.code ?? "invalid-proof", error?.message ?? "The proof does not validate");
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The proof of one accepted save, from the page state pinned to its capture.
180
+ *
181
+ * The receipt is the accepted ETag and the digest of the state the proof is made over,
182
+ * which is this tab's own account that this capture was written and hashed. Nothing
183
+ * about a later edit, a store value or an acknowledgement reaches this function.
184
+ *
185
+ * @param {Object} options
186
+ * @param {string} options.capture - the capture the save belongs to
187
+ * @param {Array<Object>} options.records - {id, revision, update, context} the caller observed
188
+ * @param {{authority: Object, lineage: Object}} options.accepted
189
+ * @param {string} options.article - the final outgoing canonical article
190
+ * @param {Object} options.state - the page state pinned to that capture
191
+ * @param {string} options.etag - the ETag the accepted save carried
192
+ * @returns {Promise<{ok: true, proof: Object, covered: Array}|{ok: false, code: string, message: string}>}
193
+ */
194
+ export async function captureProof({ capture, records = [], accepted, article, state = null, etag = null } = {}) {
195
+ if (!state) return refused("no-capture-state", "No page state was pinned to this capture");
196
+ if (typeof etag !== "string" || !etag) return refused("missing-etag", "An accepted save with no ETag proves no stored pair");
197
+ if (typeof article !== "string") return refused("missing-article", "The accepted bytes hold no article");
198
+ let projected, stateBytes;
199
+ try {
200
+ projected = articleOfPageState(state);
201
+ stateBytes = canonicalPageBytes(state);
202
+ } catch (error) {
203
+ return refused(error?.code ?? "invalid-state", error?.message ?? "The pinned page state does not decode");
204
+ }
205
+ if (projected !== article) return refused("article-mismatch", "The pinned page state does not project to the accepted article");
206
+ let proof;
207
+ try {
208
+ proof = await pageProof({
209
+ capture,
210
+ records,
211
+ accepted,
212
+ article,
213
+ state,
214
+ receipt: { file: etag, pageState: await digestHex(stateBytes) },
215
+ });
216
+ } catch (error) {
217
+ return refused(error?.code ?? "invalid-proof", error?.message ?? "The proof could not be built");
218
+ }
219
+ return covered(proof, records);
220
+ }
221
+
222
+ /**
223
+ * The proof of an accepted save, rebuilt from the page state the host persisted.
224
+ *
225
+ * The answer for the server's checkpoint, another writer's, and a save whose reply was
226
+ * lost: identity, generation and digest are checked against that exact ETag first, and
227
+ * only then is coverage asked. A host that cannot answer leaves the journal alone.
228
+ *
229
+ * @param {Object} options
230
+ * @param {Object} options.read - an answer from readPageState()
231
+ * @returns {Promise<{ok: true, proof: Object, covered: Array}|{ok: false, code: string, message: string}>}
232
+ */
233
+ export async function refetchedProof({ read, capture, records = [], accepted, article, etag = null } = {}) {
234
+ const verified = await verifyPageState(read, { accepted, article, etag });
235
+ if (!verified.ok) return verified;
236
+ let proof;
237
+ try {
238
+ proof = await pageProof({ capture, records, accepted, article, state: verified.state, receipt: verified.receipt });
239
+ } catch (error) {
240
+ return refused(error?.code ?? "invalid-proof", error?.message ?? "The proof could not be built");
241
+ }
242
+ return covered(proof, records);
243
+ }
244
+
245
+ /**
246
+ * Retire exactly the records one accepted save covers.
247
+ *
248
+ * The pinned capture is tried first, because it needs no request and no host. Only a
249
+ * capture that cannot prove these bytes asks the host, conditionally on the accepted
250
+ * ETag. Whichever answers, the channel is handed the caller's observed list and the
251
+ * proof together: it narrows the list to what the proof covers and never widens it, and
252
+ * a refusal here retires nothing.
253
+ *
254
+ * @param {Object} options
255
+ * @param {Object} options.channel - the `page.yjs` channel api
256
+ * @param {Array<Object>} options.records - {id, revision, update, context} the caller observed
257
+ * @param {string} options.capture
258
+ * @param {{authority: Object, lineage: Object}} options.accepted
259
+ * @param {string} options.article
260
+ * @param {Object} [options.state] - the page state pinned to the capture
261
+ * @param {string} options.etag
262
+ * @param {?string} [options.href] - the page-state resource, or null
263
+ * @param {Function} [options.read]
264
+ * @param {Function} [options.fetch]
265
+ * @returns {Promise<{ok: boolean, retired: Array, proof: ?Object, code?: string}>}
266
+ */
267
+ export async function checkpointAccepted({
268
+ channel,
269
+ records = [],
270
+ capture,
271
+ accepted,
272
+ article,
273
+ state = null,
274
+ etag = null,
275
+ href = null,
276
+ read = readPageState,
277
+ fetch: doFetch,
278
+ } = {}) {
279
+ if (!channel || typeof channel.checkpoint !== "function") return { ok: false, code: "no-channel", retired: [], proof: null };
280
+ let proven = await captureProof({ capture, records, accepted, article, state, etag });
281
+ if (!proven.ok) {
282
+ const answer = await read({ href, etag, fetch: doFetch });
283
+ proven = await refetchedProof({ read: answer, capture, records, accepted, article, etag });
284
+ }
285
+ if (!proven.ok) return { ok: false, code: proven.code, retired: [], proof: null };
286
+ const answer = await channel.checkpoint({ records: proven.covered, proof: proven.proof });
287
+ if (!answer || answer.ok !== true) return { ok: false, code: answer?.code ?? "checkpoint-failed", retired: [], proof: proven.proof };
288
+ return { ok: true, retired: answer.retired ?? [], proof: proven.proof };
289
+ }
@@ -11,9 +11,8 @@ import { currentSpan, selectBlocks, duplicateBlocks, deleteBlocks, asStep, typeI
11
11
  import { openAltEditor, setImageAttrs } from "./media.js";
12
12
  import { BlockDrag } from "./drag.js";
13
13
  import { openLinkEditor } from "./link.js";
14
- import { BLOCK_TYPES, MENU_ITEMS, blockTypeOf, turnInto, insertAfter, rankItems, runItem, retype, canTurnInto, insertItemsFor } from "./items.js";
14
+ import { BLOCK_TYPES, MENU_ITEMS, blockTypeOf, turnInto, insertAfter, rankItems, runItem, retype, canTurnInto, insertItemsFor, loadChartPart } from "./items.js";
15
15
  import toast from "../ui/toast-lazy.js";
16
- import { insertChartAfterTable } from "./chart.js";
17
16
  import { pickCsv } from "./csv-import.js";
18
17
  import { HISTORY_INTENT, typingIntent } from "./history-intent.js";
19
18
  import { composing } from "./markdown.js";
@@ -86,7 +85,7 @@ export const ACTION_ITEMS = [
86
85
  { key: "small", label: "Small", icon: "▫", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: 240 }) },
87
86
  { key: "medium", label: "Medium", icon: "◻", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: 480 }) },
88
87
  { key: "full", label: "Full width", icon: "⬜", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: null }) },
89
- { key: "chart", label: "Chart this table", icon: "📊", only: "table", run: (view, chrome, index) => insertChartAfterTable(view, blockStart(view.state.doc, index)) },
88
+ { key: "chart", label: "Chart this table", icon: "📊", only: "table", run: (view, chrome, index) => { const at = blockStart(view.state.doc, index); return loadChartPart(view).then(({ insertChartAfterTable }) => insertChartAfterTable(view, at)); } },
90
89
  { key: "csv", label: "Import CSV…", icon: "⇧", only: "table", focus: false, run: (view, chrome, index) => pickCsv(view, null, view.state.doc.child(index).attrs.id) },
91
90
  ];
92
91
 
@@ -217,6 +216,11 @@ function runList(view, kind) {
217
216
  if (tr.docChanged) view.dispatch(asStep(tr));
218
217
  }
219
218
 
219
+ // The four formats this release adds are commands the format gate may refuse, and
220
+ // a refusal is a notice rather than a change. The menu that offers them, and the
221
+ // commands behind it, load the first time one is chosen (format-native-commands.js).
222
+ const nativeMenu = (view, chrome, name) => import("./format-native-commands.js").then((menu) => menu.nativeMenu(view, name, chrome.more));
223
+
220
224
  function runMark(view, name) {
221
225
  if (name === "link") return openLinkEditor(view);
222
226
  const st = view.state;
@@ -560,6 +564,9 @@ class Chrome {
560
564
  const actions = ACTION_ITEMS.filter((a) => MORE_ACTIONS.has(a.key)).map((a) => ({ ...a, blocks: true }));
561
565
  return [
562
566
  ...tools,
567
+ { key: "underline", label: "Underline", icon: "U", hint: `${MOD}u`, run: (view, chrome) => nativeMenu(view, chrome, "underline") },
568
+ { key: "text_color", label: "Text color", icon: "A", hint: "\u203a", run: (view, chrome) => nativeMenu(view, chrome, "text_color") },
569
+ { key: "highlight", label: "Highlight", icon: "\u25A0", hint: "\u203a", run: (view, chrome) => nativeMenu(view, chrome, "highlight") },
563
570
  { key: "turn", label: "Turn into", icon: "\u21bb", run: (view, chrome) => chrome.openMenu("turn", chrome.more) },
564
571
  { key: "insert", label: "Insert", icon: "+", run: (view, chrome) => chrome.openMenu("insert", chrome.more, null, null, view.state.doc.child(chrome.blockIndex()).attrs.id) },
565
572
  ...actions,
@@ -668,9 +675,20 @@ class Chrome {
668
675
  this.view.focus();
669
676
  return;
670
677
  }
671
- const tr = st.tr;
672
- for (const id of wanted) retype(tr, this.idIndex(id), item);
673
- this.view.dispatch(asStep(tr).scrollIntoView());
678
+ const turn = () => {
679
+ const tr = this.view.state.tr;
680
+ for (const id of wanted) {
681
+ const at = this.idIndex(id);
682
+ if (at >= 0) retype(tr, at, item);
683
+ }
684
+ if (tr.docChanged) this.view.dispatch(asStep(tr).scrollIntoView());
685
+ return true;
686
+ };
687
+ // A block type from a later format asks the gate before it writes.
688
+ if (item.type === "quote") {
689
+ const view = this.view;
690
+ import("./format-native-commands.js").then((menu) => menu.gateTurn(view, turn));
691
+ } else turn();
674
692
  this.view.focus();
675
693
  } else if (m.mode === "actions" || m.mode === "more" || m.mode === "external") {
676
694
  if (item.blocks) this.selectCaretBlock(wanted);
@@ -15,6 +15,18 @@ export function customTitle(root) {
15
15
  return false;
16
16
  }
17
17
 
18
+ // Mark this document's title as its author's own, the way the SEO editor's custom
19
+ // title does. Returns whether it wrote the marker.
20
+ export function markCustomTitle(root) {
21
+ const head = headOf(root);
22
+ if (!head || customTitle(root)) return false;
23
+ const meta = head.ownerDocument.createElement("meta");
24
+ meta.setAttribute("name", TITLE_MODE);
25
+ meta.setAttribute("content", "custom");
26
+ head.insertBefore(meta, head.querySelector("title") ?? head.firstChild);
27
+ return true;
28
+ }
29
+
18
30
  export function titleText(root) {
19
31
  return headOf(root)?.querySelector("title")?.textContent ?? "";
20
32
  }
@@ -1,6 +1,7 @@
1
1
  import { state } from "../vendor/prosemirror.vendor.js";
2
2
  import Mutation from "../lib/mutation.js";
3
- import { titleFor, titleText, writeTitle } from "./document-title-dom.js";
3
+ import { UNTITLED } from "./document-title.js";
4
+ import { customTitle, markCustomTitle, titleFor, titleText, writeTitle } from "./document-title-dom.js";
4
5
 
5
6
  const ATOM = "\uFFFC";
6
7
 
@@ -38,6 +39,18 @@ export function refreshDocumentTitle(view) {
38
39
  return title;
39
40
  }
40
41
 
42
+ // A file written before titles followed the first H1 can carry a title its author
43
+ // typed with no H1 behind it. The first time an editor opens such a file, that title
44
+ // becomes the author's own (the SEO custom marker), so deriving never replaces it;
45
+ // a file that has an H1 keeps following it, and deleting that H1 later reads Untitled.
46
+ export function adoptAuthoredTitle(view) {
47
+ const doc = view?.dom?.ownerDocument ?? null;
48
+ if (!doc || customTitle(doc) || titleItemsOfDoc(view.state.doc).length || [ "", UNTITLED ].includes(titleText(doc).trim())) return false;
49
+ Mutation.pause();
50
+ try { return markCustomTitle(doc); }
51
+ finally { Mutation.resume(); }
52
+ }
53
+
41
54
  export function titlePlugin() {
42
55
  return new state.Plugin({
43
56
  view(editorView) {
@@ -46,6 +59,7 @@ export function titlePlugin() {
46
59
  doc = editorView.state.doc;
47
60
  refreshDocumentTitle(editorView);
48
61
  };
62
+ adoptAuthoredTitle(editorView);
49
63
  refresh();
50
64
  return {
51
65
  update(next) {
@@ -1,9 +1,12 @@
1
1
  export const UNTITLED = "Untitled";
2
2
 
3
- export function documentTitle(items, { custom = false, currentTitle = "" } = {}) {
3
+ export function documentTitle(items, { custom = false, currentTitle = "", keepWithoutHeading = false } = {}) {
4
4
  if (custom) return currentTitle;
5
5
  const first = items.find(item =>
6
6
  item.t === "heading" && (item.level ?? 2) === 1 && item.parent == null
7
7
  );
8
+ // A file the editor has not adopted yet (see adoptAuthoredTitle) may carry a title
9
+ // its author wrote with no H1 behind it; a renderer that cannot ask keeps it.
10
+ if (!first && keepWithoutHeading && !["", UNTITLED].includes(String(currentTitle).trim())) return currentTitle;
8
11
  return String(first?.c ?? "").replace(/[\s\uFFFC]+/gu, " ").trim() || UNTITLED;
9
12
  }