@morphemecms/cms 0.1.0 → 0.2.0-next-20261104150211

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 (908) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +51 -51
  4. package/astro.mjs +162 -162
  5. package/bin/morpheme.mjs +38 -38
  6. package/client.d.ts +10 -10
  7. package/defaults/blockTexts.ts +33 -33
  8. package/defaults/collections.ts +774 -774
  9. package/defaults/colors.ts +125 -125
  10. package/defaults/company.ts +20 -20
  11. package/defaults/editor.ts +178 -178
  12. package/defaults/internalLinks.ts +30 -30
  13. package/defaults/opens.ts +18 -18
  14. package/defaults/popup.tsx +93 -93
  15. package/defaults/privacy.ts +79 -79
  16. package/defaults/rules.ts +450 -450
  17. package/defaults/schema.ts +16 -16
  18. package/defaults/sitedata.ts +129 -129
  19. package/defaults/standard.ts +106 -106
  20. package/defaults/standardPages.ts +87 -87
  21. package/defaults/texts.ts +481 -481
  22. package/demo/README.md +26 -26
  23. package/demo/src/content/blog/build-a-page-from-blocks.md +19 -19
  24. package/demo/src/content/blog/colors-everyone-can-read.md +19 -19
  25. package/demo/src/content/blog/edit-your-website-from-your-phone.md +21 -21
  26. package/demo/src/content/blog/every-save-is-a-version.md +21 -21
  27. package/demo/src/content/blog/schedule-a-post.md +17 -17
  28. package/demo/src/content/blog/secure-from-day-one.md +17 -17
  29. package/demo/src/content/blog/seo-without-the-guesswork.md +23 -23
  30. package/demo/src/content/blog/switch-themes-without-losing-anything.md +23 -23
  31. package/demo/src/content/blog/the-app-store.md +21 -21
  32. package/demo/src/content/pages/about.md +22 -22
  33. package/demo/src/content/pages/app-store.md +30 -30
  34. package/demo/src/content/pages/blocks.md +98 -98
  35. package/demo/src/content/pages/blog-page.md +11 -11
  36. package/demo/src/content/pages/contact.md +16 -16
  37. package/demo/src/content/pages/get-started.md +20 -20
  38. package/demo/src/content/pages/home.md +26 -26
  39. package/demo/src/content/pages/page-not-found.md +11 -11
  40. package/demo/src/content/pages/privacy-policy.md +24 -24
  41. package/demo/src/data/blocks.json +25 -25
  42. package/demo/src/data/booking.json +6 -6
  43. package/demo/src/data/categories/apps.json +6 -6
  44. package/demo/src/data/categories/design.json +6 -6
  45. package/demo/src/data/categories/editing.json +6 -6
  46. package/demo/src/data/categories/security.json +6 -6
  47. package/demo/src/data/categories/seo.json +6 -6
  48. package/demo/src/data/colors.json +75 -75
  49. package/demo/src/data/contact.json +8 -8
  50. package/demo/src/data/cookies.json +21 -21
  51. package/demo/src/data/footer.json +27 -27
  52. package/demo/src/data/menu.json +35 -35
  53. package/demo/src/data/mobile-menu.json +25 -25
  54. package/demo/src/data/popups/booking-bar.json +13 -13
  55. package/demo/src/data/popups/try-the-editor.json +16 -16
  56. package/demo/src/data/standard-pages.json +8 -8
  57. package/demo/src/data/testimonials/sample-bakery.json +10 -10
  58. package/demo/src/data/testimonials/sample-clinic.json +10 -10
  59. package/demo/src/data/testimonials/sample-studio.json +10 -10
  60. package/demo/src/site/colors.ts +11 -11
  61. package/demo/src/site/config.ts +19 -19
  62. package/icons/lucide/LICENSE +43 -43
  63. package/icons/lucide/address-book.svg +1 -1
  64. package/icons/lucide/app-window.svg +1 -1
  65. package/icons/lucide/arrow-counter-clockwise.svg +1 -1
  66. package/icons/lucide/arrow-down.svg +1 -1
  67. package/icons/lucide/arrow-left.svg +1 -1
  68. package/icons/lucide/arrow-right.svg +1 -1
  69. package/icons/lucide/arrow-square-out.svg +1 -1
  70. package/icons/lucide/arrow-u-up-left.svg +1 -1
  71. package/icons/lucide/arrow-u-up-right.svg +1 -1
  72. package/icons/lucide/arrow-up-right.svg +1 -1
  73. package/icons/lucide/arrow-up.svg +1 -1
  74. package/icons/lucide/arrows-down-up.svg +1 -1
  75. package/icons/lucide/arrows-in-line-horizontal.svg +1 -1
  76. package/icons/lucide/arrows-out-line-horizontal.svg +1 -1
  77. package/icons/lucide/article.svg +1 -1
  78. package/icons/lucide/bank.svg +1 -1
  79. package/icons/lucide/bell.svg +1 -1
  80. package/icons/lucide/bicycle.svg +1 -1
  81. package/icons/lucide/binoculars.svg +1 -1
  82. package/icons/lucide/book-open-text.svg +1 -1
  83. package/icons/lucide/book-open.svg +1 -1
  84. package/icons/lucide/browser.svg +1 -1
  85. package/icons/lucide/buildings.svg +1 -1
  86. package/icons/lucide/bus.svg +1 -1
  87. package/icons/lucide/calendar-blank.svg +1 -1
  88. package/icons/lucide/calendar-check.svg +1 -1
  89. package/icons/lucide/car.svg +1 -1
  90. package/icons/lucide/cards.svg +1 -1
  91. package/icons/lucide/caret-down.svg +1 -1
  92. package/icons/lucide/caret-left.svg +1 -1
  93. package/icons/lucide/caret-right.svg +1 -1
  94. package/icons/lucide/caret-up.svg +1 -1
  95. package/icons/lucide/certificate.svg +1 -1
  96. package/icons/lucide/chart-line-up.svg +1 -1
  97. package/icons/lucide/chats.svg +1 -1
  98. package/icons/lucide/check-circle.svg +1 -1
  99. package/icons/lucide/check.svg +1 -1
  100. package/icons/lucide/circle-half.svg +1 -1
  101. package/icons/lucide/circle.svg +1 -1
  102. package/icons/lucide/clock-counter-clockwise.svg +1 -1
  103. package/icons/lucide/clock.svg +1 -1
  104. package/icons/lucide/code.svg +1 -1
  105. package/icons/lucide/coffee.svg +1 -1
  106. package/icons/lucide/columns.svg +1 -1
  107. package/icons/lucide/cookie.svg +1 -1
  108. package/icons/lucide/copy.svg +1 -1
  109. package/icons/lucide/credit-card.svg +1 -1
  110. package/icons/lucide/currency-circle-dollar.svg +1 -1
  111. package/icons/lucide/cursor-click.svg +1 -1
  112. package/icons/lucide/database.svg +1 -1
  113. package/icons/lucide/desktop.svg +1 -1
  114. package/icons/lucide/device-mobile.svg +1 -1
  115. package/icons/lucide/door-open.svg +1 -1
  116. package/icons/lucide/download-simple.svg +1 -1
  117. package/icons/lucide/envelope-simple.svg +1 -1
  118. package/icons/lucide/eye-slash.svg +1 -1
  119. package/icons/lucide/eye.svg +1 -1
  120. package/icons/lucide/file-pdf.svg +1 -1
  121. package/icons/lucide/file-text.svg +1 -1
  122. package/icons/lucide/file-video.svg +1 -1
  123. package/icons/lucide/file-zip.svg +1 -1
  124. package/icons/lucide/flower-lotus.svg +1 -1
  125. package/icons/lucide/folder-simple.svg +1 -1
  126. package/icons/lucide/funnel.svg +1 -1
  127. package/icons/lucide/gauge.svg +1 -1
  128. package/icons/lucide/gear.svg +1 -1
  129. package/icons/lucide/github-logo.svg +1 -1
  130. package/icons/lucide/globe.svg +1 -1
  131. package/icons/lucide/google-logo.svg +1 -1
  132. package/icons/lucide/graduation-cap.svg +1 -1
  133. package/icons/lucide/hammer.svg +1 -1
  134. package/icons/lucide/hand-heart.svg +1 -1
  135. package/icons/lucide/handshake.svg +1 -1
  136. package/icons/lucide/hard-drives.svg +1 -1
  137. package/icons/lucide/heart.svg +1 -1
  138. package/icons/lucide/house.svg +1 -1
  139. package/icons/lucide/image-square.svg +1 -1
  140. package/icons/lucide/image.svg +1 -1
  141. package/icons/lucide/info.svg +1 -1
  142. package/icons/lucide/keyboard.svg +1 -1
  143. package/icons/lucide/leaf.svg +1 -1
  144. package/icons/lucide/lifebuoy.svg +1 -1
  145. package/icons/lucide/lightbulb.svg +1 -1
  146. package/icons/lucide/link-break.svg +1 -1
  147. package/icons/lucide/link-simple.svg +1 -1
  148. package/icons/lucide/list-bullets.svg +1 -1
  149. package/icons/lucide/list-checks.svg +1 -1
  150. package/icons/lucide/list-numbers.svg +1 -1
  151. package/icons/lucide/list.svg +1 -1
  152. package/icons/lucide/lock-simple-open.svg +1 -1
  153. package/icons/lucide/lock-simple.svg +1 -1
  154. package/icons/lucide/magnifying-glass.svg +1 -1
  155. package/icons/lucide/map-pin.svg +1 -1
  156. package/icons/lucide/map-trifold.svg +1 -1
  157. package/icons/lucide/medal.svg +1 -1
  158. package/icons/lucide/megaphone.svg +1 -1
  159. package/icons/lucide/minus.svg +1 -1
  160. package/icons/lucide/moon.svg +1 -1
  161. package/icons/lucide/newspaper.svg +1 -1
  162. package/icons/lucide/pack.json +23 -23
  163. package/icons/lucide/paint-brush-broad.svg +1 -1
  164. package/icons/lucide/palette.svg +1 -1
  165. package/icons/lucide/paper-plane-tilt.svg +1 -1
  166. package/icons/lucide/pencil-simple.svg +1 -1
  167. package/icons/lucide/phone.svg +1 -1
  168. package/icons/lucide/play-fill.svg +1 -1
  169. package/icons/lucide/plus.svg +1 -1
  170. package/icons/lucide/prohibit.svg +1 -1
  171. package/icons/lucide/puzzle-piece.svg +1 -1
  172. package/icons/lucide/question.svg +1 -1
  173. package/icons/lucide/quotes.svg +1 -1
  174. package/icons/lucide/rows.svg +1 -1
  175. package/icons/lucide/ruler.svg +1 -1
  176. package/icons/lucide/scan.svg +1 -1
  177. package/icons/lucide/shapes.svg +1 -1
  178. package/icons/lucide/share-network.svg +1 -1
  179. package/icons/lucide/shield-check.svg +1 -1
  180. package/icons/lucide/sidebar-simple.svg +1 -1
  181. package/icons/lucide/sign-out.svg +1 -1
  182. package/icons/lucide/signpost.svg +1 -1
  183. package/icons/lucide/sliders-horizontal.svg +1 -1
  184. package/icons/lucide/smiley.svg +1 -1
  185. package/icons/lucide/sparkle.svg +1 -1
  186. package/icons/lucide/square-half-bottom.svg +1 -1
  187. package/icons/lucide/square.svg +1 -1
  188. package/icons/lucide/squares-four.svg +1 -1
  189. package/icons/lucide/sticker.svg +1 -1
  190. package/icons/lucide/storefront.svg +1 -1
  191. package/icons/lucide/sun.svg +1 -1
  192. package/icons/lucide/swatches.svg +1 -1
  193. package/icons/lucide/tag.svg +1 -1
  194. package/icons/lucide/text-aa.svg +1 -1
  195. package/icons/lucide/text-align-left.svg +1 -1
  196. package/icons/lucide/text-b.svg +1 -1
  197. package/icons/lucide/text-h-one.svg +1 -1
  198. package/icons/lucide/text-h-three.svg +1 -1
  199. package/icons/lucide/text-h-two.svg +1 -1
  200. package/icons/lucide/text-italic.svg +1 -1
  201. package/icons/lucide/text-t.svg +1 -1
  202. package/icons/lucide/ticket.svg +1 -1
  203. package/icons/lucide/toggle-right.svg +1 -1
  204. package/icons/lucide/train.svg +1 -1
  205. package/icons/lucide/trash.svg +1 -1
  206. package/icons/lucide/upload-simple.svg +1 -1
  207. package/icons/lucide/user-circle.svg +1 -1
  208. package/icons/lucide/user.svg +1 -1
  209. package/icons/lucide/users-three.svg +1 -1
  210. package/icons/lucide/users.svg +1 -1
  211. package/icons/lucide/video-camera.svg +1 -1
  212. package/icons/lucide/warning-circle.svg +1 -1
  213. package/icons/lucide/warning.svg +1 -1
  214. package/icons/lucide/wave-sine.svg +1 -1
  215. package/icons/lucide/wheelchair.svg +1 -1
  216. package/icons/lucide/wrench.svg +1 -1
  217. package/icons/lucide/x.svg +1 -1
  218. package/icons/material-symbols/LICENSE +201 -201
  219. package/icons/material-symbols/address-book.svg +1 -1
  220. package/icons/material-symbols/app-window.svg +1 -1
  221. package/icons/material-symbols/arrow-counter-clockwise.svg +1 -1
  222. package/icons/material-symbols/arrow-down.svg +1 -1
  223. package/icons/material-symbols/arrow-left.svg +1 -1
  224. package/icons/material-symbols/arrow-right.svg +1 -1
  225. package/icons/material-symbols/arrow-square-out.svg +1 -1
  226. package/icons/material-symbols/arrow-u-up-left.svg +1 -1
  227. package/icons/material-symbols/arrow-u-up-right.svg +1 -1
  228. package/icons/material-symbols/arrow-up-right.svg +1 -1
  229. package/icons/material-symbols/arrow-up.svg +1 -1
  230. package/icons/material-symbols/arrows-down-up.svg +1 -1
  231. package/icons/material-symbols/arrows-in-line-horizontal.svg +1 -1
  232. package/icons/material-symbols/arrows-out-line-horizontal.svg +1 -1
  233. package/icons/material-symbols/article.svg +1 -1
  234. package/icons/material-symbols/bank.svg +1 -1
  235. package/icons/material-symbols/bell.svg +1 -1
  236. package/icons/material-symbols/bicycle.svg +1 -1
  237. package/icons/material-symbols/binoculars.svg +1 -1
  238. package/icons/material-symbols/book-open-text.svg +1 -1
  239. package/icons/material-symbols/book-open.svg +1 -1
  240. package/icons/material-symbols/browser.svg +1 -1
  241. package/icons/material-symbols/buildings.svg +1 -1
  242. package/icons/material-symbols/bus.svg +1 -1
  243. package/icons/material-symbols/calendar-blank.svg +1 -1
  244. package/icons/material-symbols/calendar-check.svg +1 -1
  245. package/icons/material-symbols/car.svg +1 -1
  246. package/icons/material-symbols/cards.svg +1 -1
  247. package/icons/material-symbols/caret-down.svg +1 -1
  248. package/icons/material-symbols/caret-left.svg +1 -1
  249. package/icons/material-symbols/caret-right.svg +1 -1
  250. package/icons/material-symbols/caret-up.svg +1 -1
  251. package/icons/material-symbols/certificate.svg +1 -1
  252. package/icons/material-symbols/chart-line-up.svg +1 -1
  253. package/icons/material-symbols/chats.svg +1 -1
  254. package/icons/material-symbols/check-circle.svg +1 -1
  255. package/icons/material-symbols/check.svg +1 -1
  256. package/icons/material-symbols/circle-half.svg +1 -1
  257. package/icons/material-symbols/circle.svg +1 -1
  258. package/icons/material-symbols/clock-counter-clockwise.svg +1 -1
  259. package/icons/material-symbols/clock.svg +1 -1
  260. package/icons/material-symbols/code.svg +1 -1
  261. package/icons/material-symbols/coffee.svg +1 -1
  262. package/icons/material-symbols/columns.svg +1 -1
  263. package/icons/material-symbols/cookie.svg +1 -1
  264. package/icons/material-symbols/copy.svg +1 -1
  265. package/icons/material-symbols/credit-card.svg +1 -1
  266. package/icons/material-symbols/currency-circle-dollar.svg +1 -1
  267. package/icons/material-symbols/cursor-click.svg +1 -1
  268. package/icons/material-symbols/database.svg +1 -1
  269. package/icons/material-symbols/desktop.svg +1 -1
  270. package/icons/material-symbols/device-mobile.svg +1 -1
  271. package/icons/material-symbols/door-open.svg +1 -1
  272. package/icons/material-symbols/download-simple.svg +1 -1
  273. package/icons/material-symbols/envelope-simple.svg +1 -1
  274. package/icons/material-symbols/eye-slash.svg +1 -1
  275. package/icons/material-symbols/eye.svg +1 -1
  276. package/icons/material-symbols/file-pdf.svg +1 -1
  277. package/icons/material-symbols/file-text.svg +1 -1
  278. package/icons/material-symbols/file-video.svg +1 -1
  279. package/icons/material-symbols/file-zip.svg +1 -1
  280. package/icons/material-symbols/flower-lotus.svg +1 -1
  281. package/icons/material-symbols/folder-simple.svg +1 -1
  282. package/icons/material-symbols/funnel.svg +1 -1
  283. package/icons/material-symbols/gauge.svg +1 -1
  284. package/icons/material-symbols/gear.svg +1 -1
  285. package/icons/material-symbols/github-logo.svg +1 -1
  286. package/icons/material-symbols/globe.svg +1 -1
  287. package/icons/material-symbols/google-logo.svg +1 -1
  288. package/icons/material-symbols/graduation-cap.svg +1 -1
  289. package/icons/material-symbols/hammer.svg +1 -1
  290. package/icons/material-symbols/hand-heart.svg +1 -1
  291. package/icons/material-symbols/handshake.svg +1 -1
  292. package/icons/material-symbols/hard-drives.svg +1 -1
  293. package/icons/material-symbols/heart.svg +1 -1
  294. package/icons/material-symbols/house.svg +1 -1
  295. package/icons/material-symbols/image-square.svg +1 -1
  296. package/icons/material-symbols/image.svg +1 -1
  297. package/icons/material-symbols/info.svg +1 -1
  298. package/icons/material-symbols/keyboard.svg +1 -1
  299. package/icons/material-symbols/leaf.svg +1 -1
  300. package/icons/material-symbols/lifebuoy.svg +1 -1
  301. package/icons/material-symbols/lightbulb.svg +1 -1
  302. package/icons/material-symbols/link-break.svg +1 -1
  303. package/icons/material-symbols/link-simple.svg +1 -1
  304. package/icons/material-symbols/list-bullets.svg +1 -1
  305. package/icons/material-symbols/list-checks.svg +1 -1
  306. package/icons/material-symbols/list-numbers.svg +1 -1
  307. package/icons/material-symbols/list.svg +1 -1
  308. package/icons/material-symbols/lock-simple-open.svg +1 -1
  309. package/icons/material-symbols/lock-simple.svg +1 -1
  310. package/icons/material-symbols/magnifying-glass.svg +1 -1
  311. package/icons/material-symbols/map-pin.svg +1 -1
  312. package/icons/material-symbols/map-trifold.svg +1 -1
  313. package/icons/material-symbols/medal.svg +1 -1
  314. package/icons/material-symbols/megaphone.svg +1 -1
  315. package/icons/material-symbols/minus.svg +1 -1
  316. package/icons/material-symbols/moon.svg +1 -1
  317. package/icons/material-symbols/newspaper.svg +1 -1
  318. package/icons/material-symbols/pack.json +29 -29
  319. package/icons/material-symbols/paint-brush-broad.svg +1 -1
  320. package/icons/material-symbols/palette.svg +1 -1
  321. package/icons/material-symbols/paper-plane-tilt.svg +1 -1
  322. package/icons/material-symbols/pencil-simple.svg +1 -1
  323. package/icons/material-symbols/phone.svg +1 -1
  324. package/icons/material-symbols/play-fill.svg +1 -1
  325. package/icons/material-symbols/plus.svg +1 -1
  326. package/icons/material-symbols/prohibit.svg +1 -1
  327. package/icons/material-symbols/puzzle-piece.svg +1 -1
  328. package/icons/material-symbols/question.svg +1 -1
  329. package/icons/material-symbols/quotes.svg +1 -1
  330. package/icons/material-symbols/rows.svg +1 -1
  331. package/icons/material-symbols/ruler.svg +1 -1
  332. package/icons/material-symbols/scan.svg +1 -1
  333. package/icons/material-symbols/shapes.svg +1 -1
  334. package/icons/material-symbols/share-network.svg +1 -1
  335. package/icons/material-symbols/shield-check.svg +1 -1
  336. package/icons/material-symbols/sidebar-simple.svg +1 -1
  337. package/icons/material-symbols/sign-out.svg +1 -1
  338. package/icons/material-symbols/signpost.svg +1 -1
  339. package/icons/material-symbols/sliders-horizontal.svg +1 -1
  340. package/icons/material-symbols/smiley.svg +1 -1
  341. package/icons/material-symbols/sparkle.svg +1 -1
  342. package/icons/material-symbols/square-half-bottom.svg +1 -1
  343. package/icons/material-symbols/square.svg +1 -1
  344. package/icons/material-symbols/squares-four.svg +1 -1
  345. package/icons/material-symbols/sticker.svg +1 -1
  346. package/icons/material-symbols/storefront.svg +1 -1
  347. package/icons/material-symbols/sun.svg +1 -1
  348. package/icons/material-symbols/swatches.svg +1 -1
  349. package/icons/material-symbols/tag.svg +1 -1
  350. package/icons/material-symbols/text-aa.svg +1 -1
  351. package/icons/material-symbols/text-align-left.svg +1 -1
  352. package/icons/material-symbols/text-b.svg +1 -1
  353. package/icons/material-symbols/text-h-one.svg +1 -1
  354. package/icons/material-symbols/text-h-three.svg +1 -1
  355. package/icons/material-symbols/text-h-two.svg +1 -1
  356. package/icons/material-symbols/text-italic.svg +1 -1
  357. package/icons/material-symbols/text-t.svg +1 -1
  358. package/icons/material-symbols/ticket.svg +1 -1
  359. package/icons/material-symbols/toggle-right.svg +1 -1
  360. package/icons/material-symbols/train.svg +1 -1
  361. package/icons/material-symbols/trash.svg +1 -1
  362. package/icons/material-symbols/upload-simple.svg +1 -1
  363. package/icons/material-symbols/user-circle.svg +1 -1
  364. package/icons/material-symbols/user.svg +1 -1
  365. package/icons/material-symbols/users-three.svg +1 -1
  366. package/icons/material-symbols/users.svg +1 -1
  367. package/icons/material-symbols/video-camera.svg +1 -1
  368. package/icons/material-symbols/warning-circle.svg +1 -1
  369. package/icons/material-symbols/warning.svg +1 -1
  370. package/icons/material-symbols/wave-sine.svg +1 -1
  371. package/icons/material-symbols/wheelchair.svg +1 -1
  372. package/icons/material-symbols/wrench.svg +1 -1
  373. package/icons/material-symbols/x.svg +1 -1
  374. package/icons/phosphor/pack.json +8 -8
  375. package/lib/apps.mjs +142 -139
  376. package/lib/apps.test.d.mts +1 -0
  377. package/lib/apps.test.mjs +50 -0
  378. package/lib/blocks.mjs +90 -90
  379. package/lib/caseCollisions.d.mts +4 -0
  380. package/lib/caseCollisions.mjs +62 -0
  381. package/lib/caseCollisions.test.d.mts +1 -0
  382. package/lib/caseCollisions.test.mjs +74 -0
  383. package/lib/colors.ts +86 -86
  384. package/lib/contactBar.ts +18 -18
  385. package/lib/contracts.d.mts +15 -0
  386. package/lib/contracts.mjs +17 -0
  387. package/lib/cookieCategories.mjs +28 -28
  388. package/lib/crop.mjs +83 -83
  389. package/lib/csp.mjs +130 -130
  390. package/lib/cspSources.mjs +60 -60
  391. package/lib/dynamic-colors.ts +426 -426
  392. package/lib/git.mjs +64 -64
  393. package/lib/hooks.ts +51 -51
  394. package/lib/inline.ts +20 -20
  395. package/lib/license-texts/Apache-2.0.txt +190 -190
  396. package/lib/license-texts/BSD-2-Clause.txt +24 -24
  397. package/lib/license-texts/BSD-3-Clause.txt +28 -28
  398. package/lib/license-texts/ISC.txt +15 -15
  399. package/lib/license-texts/MIT.txt +21 -21
  400. package/lib/licenses.d.mts +1 -1
  401. package/lib/licenses.mjs +683 -683
  402. package/lib/markdown.ts +42 -42
  403. package/lib/motion-schemes.mjs +5 -5
  404. package/lib/motion.ts +131 -131
  405. package/lib/notice.mjs +34 -34
  406. package/lib/scheduled.mjs +37 -37
  407. package/lib/snackbar.ts +149 -149
  408. package/lib/status.mjs +18 -18
  409. package/lib/storeEntries.d.mts +18 -0
  410. package/lib/storeEntries.mjs +64 -0
  411. package/lib/storeEntries.test.d.mts +1 -0
  412. package/lib/storeEntries.test.mjs +71 -0
  413. package/lib/svg.mjs +231 -231
  414. package/lib/text-colors.mjs +43 -43
  415. package/lib/video-id.mjs +72 -72
  416. package/lib/video.mjs +91 -91
  417. package/package.json +136 -131
  418. package/pages/404.astro +10 -10
  419. package/pages/[...slug].astro +51 -51
  420. package/pages/_headers.txt +31 -31
  421. package/pages/blog/index.astro +10 -10
  422. package/pages/blog/kategori/[category].astro +18 -18
  423. package/pages/editor/code-images.json.js +25 -25
  424. package/pages/editor/index.astro +75 -75
  425. package/pages/editor/manifest.webmanifest.js +23 -23
  426. package/pages/editor/sw.js +33 -33
  427. package/pages/editor/sw.js.js +4 -4
  428. package/pages/editor/thumbnails.json.js +21 -21
  429. package/pages/editor/version.json.js +22 -22
  430. package/pages/headers.js +4 -4
  431. package/pages/index.astro +10 -10
  432. package/pages/ny.astro +6 -6
  433. package/pages/robots.txt.js +3 -3
  434. package/pages/rss.xml.js +20 -20
  435. package/scripts/app-screenshots.mjs +164 -164
  436. package/scripts/app-workers.mjs +6 -6
  437. package/scripts/bin.mjs +12 -12
  438. package/scripts/built-site.mjs +134 -99
  439. package/scripts/check-consent.mjs +113 -113
  440. package/scripts/check-content.mjs +151 -149
  441. package/scripts/check-demo.mjs +24 -24
  442. package/scripts/check-store.mjs +160 -0
  443. package/scripts/check-themes.mjs +45 -45
  444. package/scripts/check-worker.mjs +569 -569
  445. package/scripts/compare-html.mjs +81 -81
  446. package/scripts/declarations.mjs +10 -0
  447. package/scripts/declarations.test.mjs +32 -0
  448. package/scripts/demo-site.mjs +42 -42
  449. package/scripts/editor-icons.mjs +56 -56
  450. package/scripts/editor-roundtrip.mjs +134 -134
  451. package/scripts/editor-screens.mjs +101 -101
  452. package/scripts/git-env.mjs +9 -9
  453. package/scripts/icon-packs.mjs +151 -151
  454. package/scripts/link-counts.mjs +22 -22
  455. package/scripts/page-colors.mjs +42 -42
  456. package/scripts/releases.mjs +80 -80
  457. package/scripts/responsive-images.mjs +94 -94
  458. package/scripts/scheduled-list.mjs +15 -15
  459. package/scripts/theme-screenshots.mjs +88 -88
  460. package/scripts/types.mjs +17 -21
  461. package/scripts/video-thumbnails.mjs +89 -89
  462. package/scripts/worker-modules.mjs +34 -34
  463. package/site/appCookies.mjs +74 -74
  464. package/site/apps.ts +21 -21
  465. package/site/autolinks.mjs +200 -200
  466. package/site/blockScripts.ts +31 -31
  467. package/site/categories.ts +16 -16
  468. package/site/consent.ts +26 -26
  469. package/site/content.config.ts +84 -84
  470. package/site/content.ts +104 -104
  471. package/site/cookieList.ts +17 -17
  472. package/site/defineSite.d.mts +2 -14
  473. package/site/defineSite.mjs +131 -136
  474. package/site/directives/interact.mjs +31 -31
  475. package/site/editor-version.mjs +26 -26
  476. package/site/fill.ts +13 -13
  477. package/site/format.ts +11 -11
  478. package/site/home.ts +66 -66
  479. package/site/iconPacks.mjs +43 -43
  480. package/site/icons.mjs +605 -605
  481. package/site/images.mjs +157 -157
  482. package/site/motion.ts +13 -13
  483. package/site/noindex.mjs +21 -21
  484. package/site/pageColors.ts +110 -110
  485. package/site/popups.ts +98 -98
  486. package/site/schema.ts +189 -189
  487. package/site/sections.ts +3 -3
  488. package/site/seo.ts +28 -28
  489. package/site/site.ts +76 -76
  490. package/site/siteConfig.mjs +8 -8
  491. package/site/siteCsp.mjs +75 -75
  492. package/site/siteModules.mjs +27 -27
  493. package/site/standardPages.ts +59 -59
  494. package/site/storeTheme.d.mts +6 -0
  495. package/site/storeTheme.mjs +29 -0
  496. package/site/storeTheme.test.d.mts +1 -0
  497. package/site/storeTheme.test.mjs +53 -0
  498. package/site/testimonials.ts +48 -48
  499. package/site/text.ts +43 -43
  500. package/site/theme.mjs +483 -472
  501. package/site/tracking.client.ts +225 -225
  502. package/site/tracking.ts +9 -9
  503. package/src/ActionParts.tsx +205 -205
  504. package/src/Advanced.tsx +152 -152
  505. package/src/App.tsx +901 -904
  506. package/src/AppList.tsx +1362 -1345
  507. package/src/BlockDialog.tsx +360 -360
  508. package/src/BlockParts.tsx +652 -652
  509. package/src/BlockSettings.tsx +533 -533
  510. package/src/BrandForm.tsx +164 -164
  511. package/src/Colors.tsx +476 -476
  512. package/src/CommunicationParts.tsx +199 -199
  513. package/src/CompanyForm.tsx +209 -209
  514. package/src/Consents.tsx +175 -175
  515. package/src/ContainmentParts.tsx +185 -185
  516. package/src/Crop.tsx +152 -152
  517. package/src/DataForm.tsx +434 -434
  518. package/src/Deleted.tsx +177 -177
  519. package/src/EntryForm.tsx +740 -740
  520. package/src/FileView.tsx +30 -30
  521. package/src/FormFrame.tsx +237 -237
  522. package/src/Keywords.tsx +225 -225
  523. package/src/Library.tsx +1563 -1563
  524. package/src/Licenses.tsx +336 -336
  525. package/src/LinkSheet.tsx +138 -138
  526. package/src/MarkdownEditor.tsx +1075 -1075
  527. package/src/Media.tsx +677 -677
  528. package/src/NavigationParts.tsx +350 -350
  529. package/src/NavigationRestParts.tsx +247 -247
  530. package/src/Notifications.tsx +59 -59
  531. package/src/PagePicker.tsx +136 -136
  532. package/src/Parts.tsx +991 -991
  533. package/src/PartsShown.tsx +168 -168
  534. package/src/PickerParts.tsx +183 -183
  535. package/src/Redirects.tsx +450 -450
  536. package/src/Screen.tsx +184 -184
  537. package/src/SectionsField.tsx +65 -65
  538. package/src/SelectionParts.tsx +134 -134
  539. package/src/SeoScores.tsx +184 -184
  540. package/src/Sheet.tsx +35 -35
  541. package/src/ThemeStore.tsx +376 -376
  542. package/src/Tokens.tsx +346 -346
  543. package/src/Versions.tsx +214 -214
  544. package/src/addresses.ts +75 -75
  545. package/src/allIcons.ts +7 -7
  546. package/src/appFiles.ts +29 -29
  547. package/src/apps.ts +281 -281
  548. package/src/ask.tsx +123 -123
  549. package/src/blocks.ts +251 -251
  550. package/src/brand.ts +153 -153
  551. package/src/cache.ts +42 -42
  552. package/src/check.ts +285 -285
  553. package/src/checkSite.ts +256 -256
  554. package/src/{consents.ts → consentData.ts} +26 -26
  555. package/src/cookieList.ts +70 -70
  556. package/src/countries.ts +59 -59
  557. package/src/deploy.ts +155 -155
  558. package/src/editor.css +602 -602
  559. package/src/examples.ts +85 -85
  560. package/src/fields.tsx +942 -942
  561. package/src/frontmatter.mjs +42 -42
  562. package/src/github.ts +376 -376
  563. package/src/i18n/da-DK/advanced.ts +28 -28
  564. package/src/i18n/da-DK/app.ts +71 -71
  565. package/src/i18n/da-DK/apps.ts +90 -90
  566. package/src/i18n/da-DK/blockSettings.ts +38 -38
  567. package/src/i18n/da-DK/blocks.ts +408 -408
  568. package/src/i18n/da-DK/check.ts +25 -25
  569. package/src/i18n/da-DK/colors.ts +64 -64
  570. package/src/i18n/da-DK/common.ts +118 -118
  571. package/src/i18n/da-DK/company.ts +24 -24
  572. package/src/i18n/da-DK/consents.ts +23 -23
  573. package/src/i18n/da-DK/forms.ts +116 -116
  574. package/src/i18n/da-DK/history.ts +35 -35
  575. package/src/i18n/da-DK/index.ts +49 -49
  576. package/src/i18n/da-DK/keywords.ts +19 -19
  577. package/src/i18n/da-DK/library.ts +135 -135
  578. package/src/i18n/da-DK/licenses.ts +25 -25
  579. package/src/i18n/da-DK/media.ts +79 -79
  580. package/src/i18n/da-DK/parts.ts +696 -696
  581. package/src/i18n/da-DK/preview.ts +13 -13
  582. package/src/i18n/da-DK/redirects.ts +51 -51
  583. package/src/i18n/da-DK/seo.ts +20 -20
  584. package/src/i18n/da-DK/text.ts +35 -35
  585. package/src/i18n/da-DK/theme.ts +41 -41
  586. package/src/i18n/en-US/advanced.ts +32 -32
  587. package/src/i18n/en-US/app.ts +84 -84
  588. package/src/i18n/en-US/apps.ts +134 -134
  589. package/src/i18n/en-US/blockSettings.ts +48 -48
  590. package/src/i18n/en-US/blocks.ts +441 -441
  591. package/src/i18n/en-US/check.ts +31 -31
  592. package/src/i18n/en-US/colors.ts +82 -82
  593. package/src/i18n/en-US/common.ts +144 -144
  594. package/src/i18n/en-US/company.ts +25 -25
  595. package/src/i18n/en-US/consents.ts +25 -25
  596. package/src/i18n/en-US/forms.ts +145 -145
  597. package/src/i18n/en-US/history.ts +43 -43
  598. package/src/i18n/en-US/index.ts +51 -51
  599. package/src/i18n/en-US/keywords.ts +24 -24
  600. package/src/i18n/en-US/library.ts +176 -176
  601. package/src/i18n/en-US/licenses.ts +36 -36
  602. package/src/i18n/en-US/media.ts +80 -80
  603. package/src/i18n/en-US/parts.ts +718 -718
  604. package/src/i18n/en-US/preview.ts +15 -15
  605. package/src/i18n/en-US/redirects.ts +66 -66
  606. package/src/i18n/en-US/seo.ts +26 -26
  607. package/src/i18n/en-US/text.ts +41 -41
  608. package/src/i18n/en-US/theme.ts +41 -41
  609. package/src/i18n/index.ts +124 -124
  610. package/src/icon.tsx +160 -160
  611. package/src/iconPacks.ts +89 -89
  612. package/src/images.ts +163 -163
  613. package/src/{licenses.ts → licenseData.ts} +79 -79
  614. package/src/markdown.mjs +204 -204
  615. package/src/nav.ts +68 -68
  616. package/src/notFound.ts +28 -28
  617. package/src/{notifications.ts → notificationData.ts} +223 -223
  618. package/src/pagePreview.tsx +204 -204
  619. package/src/r2Caching.ts +55 -55
  620. package/src/{redirects.ts → redirectData.ts} +149 -149
  621. package/src/registry.tsx +188 -188
  622. package/src/schema.ts +417 -417
  623. package/src/sections.mjs +303 -303
  624. package/src/seo.ts +333 -333
  625. package/src/shared.ts +44 -44
  626. package/src/sidebar.ts +31 -31
  627. package/src/site.ts +317 -317
  628. package/src/standard/Block.astro +19 -19
  629. package/src/standard/BlockSite.astro +18 -18
  630. package/src/standard/accordion/index.tsx +47 -47
  631. package/src/standard/accordion/load.ts +23 -23
  632. package/src/standard/accordion/shared.ts +15 -15
  633. package/src/standard/accordion/view.ts +32 -32
  634. package/src/standard/buttons/fields.ts +98 -98
  635. package/src/standard/buttons/index.tsx +23 -23
  636. package/src/standard/buttons/load.ts +34 -34
  637. package/src/standard/buttons/shared.ts +12 -12
  638. package/src/standard/buttons/view.ts +30 -30
  639. package/src/standard/cards/index.tsx +71 -71
  640. package/src/standard/cards/load.ts +17 -17
  641. package/src/standard/cards/shared.ts +27 -27
  642. package/src/standard/cards/view.ts +29 -29
  643. package/src/standard/checklist/index.tsx +27 -27
  644. package/src/standard/checklist/load.ts +5 -5
  645. package/src/standard/checklist/view.ts +13 -13
  646. package/src/standard/cookies/index.tsx +17 -17
  647. package/src/standard/cookies/list.ts +56 -56
  648. package/src/standard/cookies/load.ts +11 -11
  649. package/src/standard/cookies/shared.ts +20 -20
  650. package/src/standard/cookies/view.ts +28 -28
  651. package/src/standard/faq/index.tsx +29 -29
  652. package/src/standard/faq/load.ts +14 -14
  653. package/src/standard/faq/view.ts +21 -21
  654. package/src/standard/head.ts +23 -23
  655. package/src/standard/hero/fields.ts +48 -48
  656. package/src/standard/hero/index.tsx +57 -57
  657. package/src/standard/hero/load.ts +23 -23
  658. package/src/standard/hero/view.ts +38 -38
  659. package/src/standard/links/index.tsx +41 -41
  660. package/src/standard/links/load.ts +17 -17
  661. package/src/standard/links/view.ts +25 -25
  662. package/src/standard/list/index.tsx +50 -50
  663. package/src/standard/list/load.ts +5 -5
  664. package/src/standard/list/view.ts +18 -18
  665. package/src/standard/load.ts +7 -7
  666. package/src/standard/notice/index.tsx +30 -30
  667. package/src/standard/notice/load.ts +16 -16
  668. package/src/standard/notice/view.ts +10 -10
  669. package/src/standard/points/index.tsx +42 -42
  670. package/src/standard/points/load.ts +5 -5
  671. package/src/standard/points/view.ts +12 -12
  672. package/src/standard/posts/index.tsx +132 -132
  673. package/src/standard/posts/load.ts +36 -36
  674. package/src/standard/posts/shared.ts +21 -21
  675. package/src/standard/posts/view.ts +63 -63
  676. package/src/standard/pricecards/index.tsx +57 -57
  677. package/src/standard/pricecards/load.ts +11 -11
  678. package/src/standard/pricecards/view.ts +50 -50
  679. package/src/standard/sharedTexts.ts +6 -6
  680. package/src/standard/site.ts +124 -124
  681. package/src/standard/siteLooks.ts +19 -19
  682. package/src/standard/sites.ts +13 -13
  683. package/src/standard/steps/index.tsx +28 -28
  684. package/src/standard/steps/load.ts +5 -5
  685. package/src/standard/steps/view.ts +11 -11
  686. package/src/standard/testimonials/index.tsx +87 -87
  687. package/src/standard/testimonials/list.ts +43 -43
  688. package/src/standard/testimonials/load.ts +58 -58
  689. package/src/standard/testimonials/shared.ts +13 -13
  690. package/src/standard/testimonials/view.ts +76 -76
  691. package/src/standard/themeBlocks.ts +13 -13
  692. package/src/standard/video/index.tsx +23 -23
  693. package/src/standard/video/load.ts +10 -10
  694. package/src/standard/video/view.ts +42 -42
  695. package/src/standard/view.tsx +105 -105
  696. package/src/standard/views.ts +37 -37
  697. package/src/store.ts +479 -499
  698. package/src/theme.ts +90 -90
  699. package/src/themeUi.ts +97 -97
  700. package/src/themes.ts +91 -91
  701. package/src/ui.tsx +3402 -3402
  702. package/src/virtual.d.ts +5 -5
  703. package/src/when.ts +22 -22
  704. package/starter/.githooks/pre-push +4 -4
  705. package/starter/.nvmrc +1 -1
  706. package/starter/.prettierignore +11 -11
  707. package/starter/.prettierrc.json +7 -7
  708. package/starter/README.md +27 -26
  709. package/starter/astro.config.mjs +5 -5
  710. package/starter/package.json +67 -67
  711. package/starter/public/_redirects +1 -1
  712. package/starter/public/favicon.svg +1 -1
  713. package/starter/public/logo.svg +1 -1
  714. package/starter/src/content/pages/blog.md +8 -8
  715. package/starter/src/content/pages/forside.md +9 -9
  716. package/starter/src/content/pages/kontakt.md +7 -7
  717. package/starter/src/content/pages/privatlivspolitik.md +9 -9
  718. package/starter/src/content/pages/siden-findes-ikke.md +7 -7
  719. package/starter/src/content.config.ts +2 -2
  720. package/starter/src/data/apps.json +1 -1
  721. package/starter/src/data/block-settings.json +3 -3
  722. package/starter/src/data/blocks.json +1 -1
  723. package/starter/src/data/booking.json +6 -6
  724. package/starter/src/data/colors.json +70 -70
  725. package/starter/src/data/contact.json +8 -8
  726. package/starter/src/data/cookies.json +21 -21
  727. package/starter/src/data/csp.json +1 -1
  728. package/starter/src/data/footer.json +15 -15
  729. package/starter/src/data/internal-links.json +4 -4
  730. package/starter/src/data/menu.json +4 -4
  731. package/starter/src/data/mobile-menu.json +4 -4
  732. package/starter/src/data/old-addresses.json +1 -1
  733. package/starter/src/data/redirects.json +1 -1
  734. package/starter/src/data/standard-pages.json +8 -8
  735. package/starter/src/data/theme.json +1 -1
  736. package/starter/src/env.d.ts +2 -2
  737. package/starter/src/site/config.ts +13 -13
  738. package/starter/src/site/worker.js +14 -14
  739. package/starter/tsconfig.json +17 -17
  740. package/starter/worker/apps.js +6 -6
  741. package/starter/wrangler.jsonc +39 -39
  742. package/theme/LICENSE +21 -21
  743. package/theme/blocks/ArrowLink.tsx +14 -14
  744. package/theme/blocks/BlockHead.tsx +26 -26
  745. package/theme/blocks/Panel.tsx +32 -32
  746. package/theme/blocks/Person.tsx +26 -26
  747. package/theme/blocks/accordion/PageRow.tsx +25 -25
  748. package/theme/blocks/accordion/View.tsx +17 -17
  749. package/theme/blocks/buttons/Buttons.tsx +28 -28
  750. package/theme/blocks/buttons/View.tsx +12 -12
  751. package/theme/blocks/cards/LinkCards.tsx +72 -72
  752. package/theme/blocks/cards/View.tsx +20 -20
  753. package/theme/blocks/checklist/Checks.tsx +15 -15
  754. package/theme/blocks/checklist/View.tsx +14 -14
  755. package/theme/blocks/cookies/View.tsx +36 -36
  756. package/theme/blocks/faq/Accordion.tsx +34 -34
  757. package/theme/blocks/faq/View.tsx +16 -16
  758. package/theme/blocks/faq/script.ts +56 -56
  759. package/theme/blocks/hero/View.tsx +44 -44
  760. package/theme/blocks/links/LinkGroups.tsx +24 -24
  761. package/theme/blocks/links/View.tsx +17 -17
  762. package/theme/blocks/list/Rows.tsx +24 -24
  763. package/theme/blocks/list/View.tsx +14 -14
  764. package/theme/blocks/notice/View.tsx +13 -13
  765. package/theme/blocks/points/PointList.tsx +34 -34
  766. package/theme/blocks/points/View.tsx +14 -14
  767. package/theme/blocks/posts/View.tsx +26 -26
  768. package/theme/blocks/pricecards/View.tsx +40 -40
  769. package/theme/blocks/steps/StepList.tsx +23 -23
  770. package/theme/blocks/steps/View.tsx +14 -14
  771. package/theme/blocks/testimonials/List.tsx +46 -46
  772. package/theme/blocks/testimonials/Slider.tsx +44 -44
  773. package/theme/blocks/testimonials/Testimonial.tsx +124 -124
  774. package/theme/blocks/testimonials/View.tsx +21 -21
  775. package/theme/blocks/testimonials/script.ts +52 -52
  776. package/theme/blocks/video/View.tsx +41 -41
  777. package/theme/components/ActionBar.astro +63 -63
  778. package/theme/components/AppSlot.astro +21 -21
  779. package/theme/components/ArrowLink.tsx +21 -21
  780. package/theme/components/BlogIndex.astro +48 -48
  781. package/theme/components/BlogPosts.astro +18 -18
  782. package/theme/components/BlogPosts.tsx +45 -45
  783. package/theme/components/BookButton.tsx +22 -22
  784. package/theme/components/Container.astro +11 -11
  785. package/theme/components/CookieConsent.astro +121 -121
  786. package/theme/components/EndBlock.astro +21 -21
  787. package/theme/components/FontPreload.astro +6 -6
  788. package/theme/components/Footer.astro +85 -85
  789. package/theme/components/Header.astro +186 -186
  790. package/theme/components/Icon.astro +16 -16
  791. package/theme/components/Img.astro +29 -29
  792. package/theme/components/Img.tsx +33 -33
  793. package/theme/components/Inline.astro +11 -11
  794. package/theme/components/Logo.astro +23 -23
  795. package/theme/components/LogoMark.astro +10 -10
  796. package/theme/components/Markdown.astro +17 -17
  797. package/theme/components/MobileMenu.tsx +224 -224
  798. package/theme/components/MobileNav.astro +33 -33
  799. package/theme/components/PageHeader.astro +22 -22
  800. package/theme/components/PageSection.astro +24 -24
  801. package/theme/components/PhoneButton.tsx +11 -11
  802. package/theme/components/Popup.astro +145 -145
  803. package/theme/components/PopupButton.tsx +24 -24
  804. package/theme/components/PopupView.tsx +48 -48
  805. package/theme/components/PostList.tsx +58 -58
  806. package/theme/components/PostMeta.tsx +17 -17
  807. package/theme/components/RichText.astro +28 -28
  808. package/theme/components/Section.astro +29 -29
  809. package/theme/components/SectionHeading.tsx +8 -8
  810. package/theme/components/TextSection.astro +16 -16
  811. package/theme/components/home/PageBlock.astro +19 -19
  812. package/theme/components/home/Text.astro +13 -13
  813. package/theme/components/loadPopup.ts +22 -22
  814. package/theme/components/loadPosts.ts +16 -16
  815. package/theme/components/posts.ts +15 -15
  816. package/theme/editor/looks.mjs +7 -7
  817. package/theme/editor/previews.tsx +56 -56
  818. package/theme/layouts/Base.astro +273 -273
  819. package/theme/layouts/PageLayout.astro +126 -126
  820. package/theme/layouts/PostLayout.astro +137 -137
  821. package/theme/scripts/motion.ts +29 -29
  822. package/theme/styles/base.css +320 -320
  823. package/theme/styles/blocks.css +1808 -1808
  824. package/theme/styles/components.css +206 -206
  825. package/theme/styles/global.css +1302 -1302
  826. package/theme/styles/ui.css +6544 -6544
  827. package/theme/templates/Blog.astro +38 -38
  828. package/theme/templates/Category.astro +19 -19
  829. package/theme/templates/Home.astro +36 -36
  830. package/theme/templates/New.astro +68 -68
  831. package/theme/templates/NotFound.astro +117 -117
  832. package/theme/theme.json +28 -28
  833. package/theme/ui/Badge.tsx +45 -45
  834. package/theme/ui/BottomSheet.tsx +148 -148
  835. package/theme/ui/Button.tsx +79 -79
  836. package/theme/ui/ButtonGroup.tsx +31 -31
  837. package/theme/ui/Card.tsx +45 -45
  838. package/theme/ui/Carousel.tsx +124 -124
  839. package/theme/ui/Checkbox.tsx +59 -59
  840. package/theme/ui/Chip.tsx +101 -101
  841. package/theme/ui/ChipSet.tsx +27 -27
  842. package/theme/ui/DatePicker.tsx +149 -149
  843. package/theme/ui/DatePickerContainer.tsx +316 -316
  844. package/theme/ui/Dialog.tsx +100 -100
  845. package/theme/ui/Divider.tsx +27 -27
  846. package/theme/ui/ExposedDropdownMenu.tsx +77 -77
  847. package/theme/ui/Fab.tsx +88 -88
  848. package/theme/ui/FabMenu.tsx +67 -67
  849. package/theme/ui/IconButton.tsx +78 -78
  850. package/theme/ui/List.tsx +287 -287
  851. package/theme/ui/LoadingIndicator.tsx +29 -29
  852. package/theme/ui/Menu.tsx +278 -278
  853. package/theme/ui/NavigationBar.tsx +62 -62
  854. package/theme/ui/NavigationDrawer.tsx +80 -80
  855. package/theme/ui/NavigationRail.tsx +154 -154
  856. package/theme/ui/ProgressIndicator.tsx +162 -162
  857. package/theme/ui/RadioButton.tsx +50 -50
  858. package/theme/ui/Search.tsx +129 -129
  859. package/theme/ui/SegmentedButton.tsx +49 -49
  860. package/theme/ui/SideSheet.tsx +54 -54
  861. package/theme/ui/Slider.tsx +155 -155
  862. package/theme/ui/Snackbar.tsx +67 -67
  863. package/theme/ui/SplitButton.tsx +67 -67
  864. package/theme/ui/Switch.tsx +60 -60
  865. package/theme/ui/Tabs.tsx +114 -114
  866. package/theme/ui/TextField.tsx +278 -278
  867. package/theme/ui/TimePicker.tsx +128 -128
  868. package/theme/ui/TimePickerContainer.tsx +234 -234
  869. package/theme/ui/Toolbar.tsx +67 -67
  870. package/theme/ui/Tooltip.tsx +58 -58
  871. package/theme/ui/TopAppBar.tsx +77 -77
  872. package/theme/ui/bottomSheet.script.ts +8 -8
  873. package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +211 -211
  874. package/theme/ui/carousel.script.ts +28 -28
  875. package/theme/ui/carouselLayout.ts +229 -229
  876. package/theme/ui/checkbox.script.ts +8 -8
  877. package/theme/ui/datePicker.script.ts +302 -302
  878. package/theme/ui/dates.ts +185 -185
  879. package/theme/ui/{dialog.ts → dialogControl.ts} +48 -48
  880. package/theme/ui/dropdownMenu.script.ts +166 -166
  881. package/theme/ui/fabMenu.script.ts +57 -57
  882. package/theme/ui/navigationRail.script.ts +47 -47
  883. package/theme/ui/ripple.script.ts +159 -159
  884. package/theme/ui/search.script.ts +92 -92
  885. package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +48 -48
  886. package/theme/ui/slider.script.ts +12 -12
  887. package/theme/ui/sliderUpdate.ts +52 -52
  888. package/theme/ui/snackbar.script.ts +16 -16
  889. package/theme/ui/splitButton.script.ts +63 -63
  890. package/theme/ui/tabs.script.ts +48 -48
  891. package/theme/ui/textField.script.ts +17 -17
  892. package/theme/ui/textFieldUpdate.ts +44 -44
  893. package/theme/ui/timePicker.script.ts +248 -248
  894. package/theme/ui/toolbar.script.ts +43 -43
  895. package/theme/ui/tooltip.script.ts +68 -68
  896. package/worker/consent.js +141 -141
  897. package/worker/editor-github.js +167 -167
  898. package/worker/editor-host.js +74 -74
  899. package/worker/files.js +401 -401
  900. package/worker/github.js +36 -36
  901. package/worker/index.js +188 -184
  902. package/worker/not-found.js +97 -97
  903. package/worker/oauth.js +95 -95
  904. package/worker/publish.js +52 -52
  905. package/worker/push.js +270 -270
  906. package/worker/r2-status.js +50 -50
  907. package/worker/session.js +58 -58
  908. package/worker/store.js +46 -0
@@ -1,1075 +1,1075 @@
1
- // The text of a page or blog post: Milkdown (packages/editor/src/markdown.mjs) with a small toolbar.
2
- // The text is shown in the site's own style (the "prose" classes of the pages). On pages the site's
3
- // blocks (src/apps/<app>/blocks/) can be put in the text with a button; each is a card with the block as it looks
4
- // on the site, which opens a menu (Ret, hide on the site, move, Fjern), with a free line kept between
5
- // two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
6
- // and work the same.
7
- import { forwardRef, Fragment as ReactFragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
8
- import { editorViewCtx, type Editor } from '@milkdown/kit/core';
9
- import type { CmdKey } from '@milkdown/kit/core';
10
- import type { Ctx } from '@milkdown/kit/ctx';
11
- import {
12
- imageSchema,
13
- insertImageCommand,
14
- toggleEmphasisCommand,
15
- toggleStrongCommand,
16
- turnIntoTextCommand,
17
- wrapInBlockquoteCommand,
18
- wrapInBulletListCommand,
19
- wrapInHeadingCommand,
20
- wrapInOrderedListCommand,
21
- } from '@milkdown/kit/preset/commonmark';
22
- import { history, redoCommand, undoCommand } from '@milkdown/kit/plugin/history';
23
- import { clipboard } from '@milkdown/kit/plugin/clipboard';
24
- import { $prose, $view, callCommand } from '@milkdown/kit/utils';
25
- import { Fragment } from '@milkdown/kit/prose/model';
26
- import type { Mark, Node } from '@milkdown/kit/prose/model';
27
- import { Decoration, DecorationSet, type EditorView } from '@milkdown/kit/prose/view';
28
- import { NodeSelection, Plugin, Selection, TextSelection, type EditorState } from '@milkdown/kit/prose/state';
29
- import { liftListItem } from '@milkdown/kit/prose/schema-list';
30
- import { lift } from '@milkdown/kit/prose/commands';
31
- import {
32
- ArrowUUpLeft,
33
- ArrowUUpRight,
34
- Image,
35
- LinkSimple,
36
- ListBullets,
37
- ListNumbers,
38
- Plus,
39
- Quotes,
40
- TextAa,
41
- TextB,
42
- TextHThree,
43
- TextHOne,
44
- TextHTwo,
45
- TextItalic,
46
- TextT,
47
- } from '@phosphor-icons/react';
48
- import { HTML, makeEditor, markdownOf, partsOf, siteBlockSchema, spacing } from './markdown.mjs';
49
- import { TEXT_COLORS, colorClass } from '../lib/text-colors.mjs';
50
- import { IMAGE, type BlockAsk } from './BlockDialog';
51
- import { askLink } from './LinkSheet';
52
- import { Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
53
- import { shownAt } from './images';
54
- import { cropStyle, joinPicture, splitPicture } from '../lib/crop.mjs';
55
- import { describe, htmlBlock, type BlockProps } from './blocks';
56
- import { preview } from './registry';
57
- import { leaveTo } from './FormFrame';
58
- import { createRoot } from 'react-dom/client';
59
- import { flushSync } from 'react-dom';
60
- import { t } from './i18n';
61
- import { motionTiming } from '../lib/motion';
62
- // The icons of the card's menu (Phosphor, as everywhere in the editor), as SVG: the card is not React.
63
- import pencilIcon from '@phosphor-icons/core/assets/regular/pencil-simple.svg?raw';
64
- import eyeIcon from '@phosphor-icons/core/assets/regular/eye.svg?raw';
65
- import eyeSlashIcon from '@phosphor-icons/core/assets/regular/eye-slash.svg?raw';
66
- import arrowUpIcon from '@phosphor-icons/core/assets/regular/arrow-up.svg?raw';
67
- import arrowDownIcon from '@phosphor-icons/core/assets/regular/arrow-down.svg?raw';
68
- import trashIcon from '@phosphor-icons/core/assets/regular/trash.svg?raw';
69
- import boldIcon from '@phosphor-icons/core/assets/regular/text-b.svg?raw';
70
- import italicIcon from '@phosphor-icons/core/assets/regular/text-italic.svg?raw';
71
- import linkIcon from '@phosphor-icons/core/assets/regular/link-simple.svg?raw';
72
- import h1Icon from '@phosphor-icons/core/assets/regular/text-h-one.svg?raw';
73
- import h2Icon from '@phosphor-icons/core/assets/regular/text-h-two.svg?raw';
74
- import h3Icon from '@phosphor-icons/core/assets/regular/text-h-three.svg?raw';
75
- import paletteIcon from '@phosphor-icons/core/assets/regular/palette.svg?raw';
76
- // ProseMirror's own styles: keep its invisible helpers (around pictures, at the end of lines) out of the layout.
77
- import '@milkdown/kit/prose/view/style/prosemirror.css';
78
-
79
- // parts: the text in parts, for a fixed page's sections (partsOf in markdown.mjs).
80
- export type MarkdownHandle = { markdown: () => string | null; parts: () => ReturnType<typeof partsOf> | null };
81
-
82
- // addImage: asks for a picture (the gallery, Media.tsx); its address on the site. Its description is
83
- // asked in the sheet. Without it there is no picture button. blocks: asks which block to put in and its fields (BlockDialog);
84
- // without it there is no block button; with it a free line is kept between two blocks, and before
85
- // and after them (spacing in markdown.mjs). short: a smaller field, for a short text. h1: the page's own heading, "Overskrift
86
- // (H1)", can be chosen too (a page's text, which has its H1 at the top; a blog post's is its title).
87
- type Props = {
88
- value: string;
89
- onChange: () => void;
90
- addImage?: () => Promise<string | null>;
91
- blocks?: BlockAsk;
92
- /** The sheet a picture or HTML is changed in, when there are no blocks (a blog post). */
93
- sheet?: BlockAsk;
94
- short?: boolean;
95
- h1?: boolean;
96
- /** How each part of the text looks on the site (looksOf in sections.mjs), shown so (editor.css). */
97
- looks?: (items: LookItem[]) => (string | null)[];
98
- /** The headings (H2) are the names of sections (a fixed page's, the front page's: sections.mjs), which the site
99
- * draws as text: no colour for them. */
100
- sections?: boolean;
101
- };
102
-
103
- // A part of the text at the top level, for `looks`.
104
- export type LookItem = { kind: 'block'; type: string } | { kind: 'heading' | 'gap' | 'other' } | { kind: 'p'; image: boolean };
105
-
106
- const lookItem = (node: Node): LookItem =>
107
- node.type.name === 'siteBlock'
108
- ? { kind: 'block', type: node.attrs.type }
109
- : node.type.name === 'heading' && node.attrs.level === 2
110
- ? { kind: 'heading' }
111
- : node.type.name !== 'paragraph'
112
- ? { kind: 'other' }
113
- : node.content.size === 0
114
- ? { kind: 'gap' }
115
- : { kind: 'p', image: node.content.content.some((n) => n.type.name === 'image') };
116
-
117
- // An empty paragraph: a free line between two blocks, which the arrows on a card skip.
118
- const isGap = (node: Node) => node.type.name === 'paragraph' && node.content.size === 0;
119
-
120
- // A card: a block, or a picture (in a paragraph of its own).
121
- const isCard = (node: Node | null | undefined) => !!node && (node.type.name === 'siteBlock' || node.type.name === 'image');
122
-
123
- // Whether deleting back (or forward) from the cursor reaches a card: one is selected, right before
124
- // (after) the cursor, or before (after) the text the cursor is at the start (end) of.
125
- function nextToCard(state: EditorState, back: boolean) {
126
- const { selection } = state;
127
- if (selection instanceof NodeSelection) return isCard(selection.node);
128
- if (!selection.empty) return false;
129
- const $pos = selection.$from;
130
- if (isCard(back ? $pos.nodeBefore : $pos.nodeAfter)) return true;
131
- if ($pos.parentOffset !== (back ? 0 : $pos.parent.content.size)) return false;
132
- const next = $pos.node(-1).maybeChild($pos.index(-1) + (back ? -1 : 1));
133
- return isCard(next) || isCard(back ? next?.lastChild : next?.firstChild);
134
- }
135
-
136
- // Deleting next to a card is ProseMirror's, as with a hardware keyboard (the card is selected, then
137
- // removed). The keyboards of phones delete without a key (only beforeinput), and the browser then
138
- // deleted the card, or what is in it, without ProseMirror knowing.
139
- const cardDeletes = $prose(
140
- () =>
141
- new Plugin({
142
- props: {
143
- handleDOMEvents: {
144
- beforeinput: (view, event) => {
145
- const back = /^delete\w*Backward$/.test(event.inputType);
146
- if (!back && !/^delete\w*Forward$/.test(event.inputType)) return false;
147
- if (!nextToCard(view.state, back)) return false;
148
- event.preventDefault();
149
- const key = new KeyboardEvent('keydown', { key: back ? 'Backspace' : 'Delete', keyCode: back ? 8 : 46 });
150
- view.someProp('handleKeyDown', (f) => f(view, key));
151
- return true;
152
- },
153
- },
154
- },
155
- }),
156
- );
157
-
158
- // The free line the cursor is in, which opens up to write in (editor.css; the others are thin).
159
- const gapHere = $prose(
160
- () =>
161
- new Plugin({
162
- props: {
163
- decorations: ({ doc, selection: { $from, empty } }) =>
164
- empty && $from.depth === 1 && isGap($from.parent)
165
- ? DecorationSet.create(doc, [Decoration.node($from.before(), $from.after(), { class: 'editor-gap-here' })])
166
- : null,
167
- },
168
- }),
169
- );
170
-
171
- // The menu of the card that is open, if any: one at a time; closed by a tap elsewhere or Escape.
172
- let closeOpenMenu: (() => void) | null = null;
173
-
174
- // action: keyboard, whether the choice was made with the keyboard (Enter or Space).
175
- type CardItem = { icon: string; label: string; action: (keyboard: boolean) => void; danger?: boolean };
176
-
177
- // The part of the text at the top level before or after the one at index i (a block, or a paragraph
178
- // with a picture), skipping the free lines between; null at the start or end.
179
- function neighbour(doc: Node, i: number, by: -1 | 1) {
180
- let j = i + by;
181
- while (doc.maybeChild(j) && isGap(doc.child(j))) j += by;
182
- return doc.maybeChild(j) ? j : null;
183
- }
184
-
185
- // The element of the part at the top level at index k.
186
- const partDOM = (view: EditorView, k: number) => view.nodeDOM(view.state.doc.resolve(0).posAtIndex(k)) as HTMLElement | null;
187
-
188
- // Swaps the part at the top level at index i with the one before or after it; free lines between
189
- // stay. The parts glide from where they were to where they are now. Returns the moved part's new
190
- // index, or null when it cannot move.
191
- function move(view: EditorView, i: number, by: -1 | 1): number | null {
192
- const { doc } = view.state;
193
- const j = neighbour(doc, i, by);
194
- if (j === null) return null;
195
- const node = doc.child(i);
196
- const [first, last] = by < 0 ? [j, i] : [i, j];
197
- const nodes: Node[] = [];
198
- for (let k = first; k <= last; k++) nodes.push(doc.child(k));
199
- // Where each part is now, to let it glide from there; by the node, as the text may be tidied after
200
- // the move (a section's heading goes with its card).
201
- const tops = new Map<Node, number>();
202
- doc.forEach((child, _, k) => {
203
- const top = partDOM(view, k)?.getBoundingClientRect().top;
204
- if (top !== undefined) tops.set(child, top);
205
- });
206
- const $start = doc.resolve(0);
207
- const from = $start.posAtIndex(first);
208
- const to = $start.posAtIndex(last) + doc.child(last).nodeSize;
209
- const tr = view.state.tr.replaceWith(from, to, Fragment.from([nodes[nodes.length - 1], ...nodes.slice(1, -1), nodes[0]]));
210
- // The cursor goes after the moved part, not on a block, which the next key would replace.
211
- view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(by < 0 ? from + node.nodeSize : to))));
212
- let at = -1;
213
- view.state.doc.forEach((child, _, k) => {
214
- if (child === node) at = k;
215
- const was = tops.get(child);
216
- const el = partDOM(view, k);
217
- const dy = was === undefined || !el ? 0 : was - el.getBoundingClientRect().top;
218
- if (el && Math.abs(dy) > 1 && !matchMedia('(prefers-reduced-motion: reduce)').matches)
219
- el.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], motionTiming('spatial'));
220
- });
221
- if (at < 0) return null;
222
- // Where it went, in view (one step: past one block, paragraph, heading or picture).
223
- const moved = partDOM(view, at);
224
- requestAnimationFrame(() => moved?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
225
- return at;
226
- }
227
-
228
- // Opens the menu of the card at the top level at index k again (after it moved, it is a new card).
229
- type CardHost = HTMLElement & { openMenu?: (keyboard: boolean, focus?: string) => void };
230
- function reopen(view: EditorView, k: number, keyboard: boolean, label: string) {
231
- const el = partDOM(view, k);
232
- const host = (el?.matches('.site-block') ? el : el?.querySelector('.site-block')) as CardHost | null;
233
- host?.openMenu?.(keyboard, label);
234
- }
235
-
236
- // The blocks at the top level of the text, in order: their type and whether they are hidden.
237
- const blocksOf = (doc: Node) => {
238
- const blocks: { type: string; hidden: boolean }[] = [];
239
- doc.forEach((n) => void (n.type.name === 'siteBlock' && blocks.push({ type: n.attrs.type, hidden: n.attrs.props?.hidden === true })));
240
- return blocks;
241
- };
242
-
243
- // "Flyt op" and "Flyt ned" for the part at the top level at index i, where it can be moved: not past
244
- // the start or end, nor past a block the template locks (locked: their types). The menu stays open on
245
- // the moved card, so it can be moved again at once.
246
- const moves = (view: EditorView, i: number | null, locked: string[] = []): CardItem[] => {
247
- if (i === null) return [];
248
- const step = (by: -1 | 1) => (keyboard: boolean) => {
249
- const at = move(view, i, by);
250
- if (at !== null) reopen(view, at, keyboard, by < 0 ? t.common.moveUp : t.common.moveDown);
251
- };
252
- const can = (by: -1 | 1) => {
253
- const j = neighbour(view.state.doc, i, by);
254
- const next = j === null ? null : view.state.doc.child(j);
255
- return !!next && !(next.type.name === 'siteBlock' && locked.includes(next.attrs.type));
256
- };
257
- return [
258
- ...(can(-1) ? [{ icon: arrowUpIcon, label: t.common.moveUp, action: step(-1) }] : []),
259
- ...(can(1) ? [{ icon: arrowDownIcon, label: t.common.moveDown, action: step(1) }] : []),
260
- ];
261
- };
262
-
263
- // The row of icons on a card (a block or a picture) in the text, opened by tapping it: items, as
264
- // they are when it opens. Returns how to close it.
265
- function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: string, items: () => (CardItem | undefined | '')[]) {
266
- let menu: HTMLElement | null = null;
267
- function close() {
268
- menu?.remove();
269
- menu = null;
270
- card.setAttribute('aria-expanded', 'false');
271
- document.removeEventListener('pointerdown', outside, true);
272
- document.removeEventListener('keydown', escape, true);
273
- if (closeOpenMenu === close) closeOpenMenu = null;
274
- }
275
- const outside = (e: Event) => void (dom.contains(e.target as globalThis.Node) || close());
276
- const escape = (e: KeyboardEvent) => {
277
- if (e.key !== 'Escape') return;
278
- close();
279
- card.focus();
280
- };
281
- // keyboard: opened with Enter or Space, so a choice gets the focus: the first, or the one named
282
- // `focus` (the arrow used to move the card, when it opens again on the moved card).
283
- function open(keyboard: boolean, focus?: string) {
284
- closeOpenMenu?.();
285
- menu = dom.appendChild(document.createElement('div'));
286
- menu.className = 'site-block-menu';
287
- menu.setAttribute('role', 'menu');
288
- menu.setAttribute('aria-label', name);
289
- for (const it of items()) {
290
- if (!it) continue;
291
- const b = menu.appendChild(document.createElement('button'));
292
- b.type = 'button';
293
- b.setAttribute('role', 'menuitem');
294
- b.setAttribute('aria-label', it.label);
295
- b.title = it.label;
296
- b.innerHTML = it.icon.replace('<svg', '<svg width="20" height="20" fill="currentColor" aria-hidden="true"');
297
- if (it.danger) b.className = 'site-block-danger';
298
- b.addEventListener('click', (e) => {
299
- close();
300
- it.action(e.detail === 0);
301
- });
302
- }
303
- card.setAttribute('aria-expanded', 'true');
304
- closeOpenMenu = close;
305
- document.addEventListener('pointerdown', outside, true);
306
- document.addEventListener('keydown', escape, true);
307
- // On a card taller than the screen, under the toolbar (not above the screen) where the card goes on.
308
- const bar = document.querySelector('.editor-tools')?.getBoundingClientRect().bottom ?? 0;
309
- const box = dom.getBoundingClientRect();
310
- const at = menu.getBoundingClientRect();
311
- if (at.top < bar + 8) menu.style.top = `${Math.min(bar + 8 - box.top, box.height - at.height - 8)}px`;
312
- if (keyboard)
313
- (menu.querySelector<HTMLElement>(`[aria-label="${focus}"]`) ?? menu.querySelector('button'))?.focus({ preventScroll: true });
314
- }
315
- (dom as CardHost).openMenu = open;
316
- card.setAttribute('aria-haspopup', 'menu');
317
- card.setAttribute('aria-expanded', 'false');
318
- card.addEventListener('mousedown', (e) => e.preventDefault());
319
- // While a finger is on the card, the text cannot be written in: a phone does not put the cursor in
320
- // the text next to the card and open the keyboard (and a keyboard that is open closes).
321
- card.addEventListener('pointerdown', (e) => {
322
- if (e.pointerType === 'mouse' || view.props.editable?.(view.state) === false) return;
323
- const before = view.props.editable;
324
- view.setProps({ editable: () => false });
325
- let timer = 0;
326
- const unlock = () => {
327
- clearTimeout(timer);
328
- card.removeEventListener('click', later);
329
- document.removeEventListener('pointercancel', unlock, true);
330
- view.setProps({ editable: before });
331
- };
332
- const later = () => setTimeout(unlock);
333
- card.addEventListener('click', later, { once: true });
334
- document.addEventListener('pointercancel', unlock, { capture: true, once: true });
335
- timer = window.setTimeout(unlock, 1000);
336
- });
337
- card.addEventListener('click', (e) => (menu ? close() : open(e.detail === 0)));
338
- return close;
339
- }
340
-
341
- // The address of a picture from its sheet: with its format and crop after it, and marked when it is decorative
342
- // (crop.mjs).
343
- const pictureSrc = (props: BlockProps) =>
344
- joinPicture(
345
- String(props.src),
346
- String(props.format ?? ''),
347
- (props.crop as number[] | null | undefined) ?? null,
348
- props.decorative === true,
349
- );
350
-
351
- // A picture in the text as a card like a block's, cut to its format, with a menu to change it
352
- // (another picture, its description for screen readers, its format), move it with its paragraph and
353
- // remove it.
354
- // sheet: where it is changed (the sheet of the blocks, BlockDialog.tsx).
355
- function pictureCard(node: Node, view: EditorView, getPos: () => number | undefined, sheet: () => BlockAsk | undefined) {
356
- const dom = document.createElement('span');
357
- dom.className = 'site-block site-picture';
358
- dom.contentEditable = 'false';
359
- const card = dom.appendChild(document.createElement('button'));
360
- card.type = 'button';
361
- card.className = 'site-block-card';
362
- card.setAttribute('aria-label', t.text.image);
363
- // Cut to its format as on the site (crop.mjs): in a box of the format.
364
- const { src, format, crop, decorative } = splitPicture(node.attrs.src);
365
- if (node.attrs.alt && !decorative) card.setAttribute('aria-description', node.attrs.alt);
366
- const cut = format ? cropStyle(format, crop) : null;
367
- const box = cut?.box ? card.appendChild(document.createElement('span')) : card;
368
- if (cut?.box) {
369
- box.className = 'picture-crop';
370
- box.style.cssText = cut.box;
371
- }
372
- const img = box.appendChild(document.createElement('img'));
373
- img.src = shownAt(src);
374
- img.alt = '';
375
- if (cut) img.style.cssText = cut.img;
376
- if (node.attrs.title) img.title = node.attrs.title;
377
- const describe = async () => {
378
- const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative });
379
- const pos = getPos();
380
- if (!props || pos === undefined) return;
381
- view.dispatch(
382
- view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: pictureSrc(props), alt: String(props.alt ?? '').trim() }),
383
- );
384
- };
385
- const remove = () => {
386
- const pos = getPos();
387
- if (pos !== undefined) view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
388
- };
389
- const close = cardMenu(view, dom, card, t.text.image, () => {
390
- const pos = getPos();
391
- return [
392
- { icon: pencilIcon, label: t.common.edit, action: describe },
393
- ...moves(view, pos === undefined ? null : view.state.doc.resolve(pos).index(0), sheet()?.template?.locked),
394
- { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
395
- ];
396
- });
397
- return { dom, update: () => false, stopEvent: () => true, ignoreMutation: () => true, destroy: close };
398
- }
399
-
400
- // The link where the cursor is, or in the selection: where it is (all of it, when the cursor is in it)
401
- // and its address; null when there is none.
402
- function linkAt(state: EditorView['state']) {
403
- const type = state.schema.marks.link;
404
- const { from, to, empty, $from } = state.selection;
405
- if (!empty) {
406
- let href = '';
407
- state.doc.nodesBetween(from, to, (n) => void (href ||= type.isInSet(n.marks)?.attrs.href ?? ''));
408
- return href ? { from, to, href } : null;
409
- }
410
- // The text with the same link around the cursor.
411
- type Run = { from: number; to: number; mark: Mark };
412
- const start = $from.start();
413
- let run: Run | null = null;
414
- let found = null as Run | null;
415
- $from.parent.forEach((child, offset) => {
416
- const mark = type.isInSet(child.marks);
417
- const a = start + offset;
418
- const b = a + child.nodeSize;
419
- if (mark && run && run.mark.eq(mark) && run.to === a) run.to = b;
420
- else run = mark ? { from: a, to: b, mark } : null;
421
- if (run && run.from < from && from < run.to) found = { ...run };
422
- });
423
- return found && { from: found.from, to: found.to, href: String(found.mark.attrs.href) };
424
- }
425
-
426
- // The toolbar's and the selection's "Link": the link sheet (LinkSheet.tsx), then the selection, or
427
- // the link the cursor is in, gets the address, or loses its link; with nothing selected, the page's
428
- // name (or the address) is put in as the link.
429
- async function editLink(view: EditorView) {
430
- const link = linkAt(view.state);
431
- const answer = await askLink(link?.href ?? '');
432
- view.focus();
433
- if (!answer) return;
434
- const { state } = view;
435
- const type = state.schema.marks.link;
436
- const { from, to } = link ?? state.selection;
437
- if (from === to) {
438
- if (answer.href)
439
- view.dispatch(state.tr.insertText(answer.text, from).addMark(from, from + answer.text.length, type.create({ href: answer.href })));
440
- return;
441
- }
442
- const tr = state.tr.removeMark(from, to, type);
443
- view.dispatch(answer.href ? tr.addMark(from, to, type.create({ href: answer.href })) : tr);
444
- }
445
-
446
- /** What applies where the cursor is, or to all of the selection: 'strong', 'emphasis', 'link',
447
- * 'h2', 'h3', 'text' (a paragraph, not in a list or quote), 'bullet', 'ordered' and 'quote'; with a colour
448
- * (text-colors.mjs) 'textColor' and the colour's class ('color-accent'). For the toolbar and the selection's menu,
449
- * which mark them. */
450
- export function activeOf(state: EditorView['state']): Set<string> {
451
- const { selection, storedMarks, schema } = state;
452
- const { $from, from, to, empty } = selection;
453
- const on = new Set<string>();
454
- for (const name of ['strong', 'emphasis', 'link', 'textColor']) {
455
- const mark = schema.marks[name];
456
- if (empty ? mark.isInSet(storedMarks ?? $from.marks()) : state.doc.rangeHasMark(from, to, mark)) on.add(name);
457
- }
458
- const color = colorAt(state);
459
- if (color) on.add(colorClass(color));
460
- const ancestors = Array.from({ length: $from.depth }, (_, i) => $from.node(i + 1).type.name);
461
- if ($from.parent.type.name === 'heading') on.add(`h${$from.parent.attrs.level}`);
462
- if (ancestors.includes('bullet_list')) on.add('bullet');
463
- if (ancestors.includes('ordered_list')) on.add('ordered');
464
- if (ancestors.includes('blockquote')) on.add('quote');
465
- if ($from.parent.type.name === 'paragraph' && !on.has('bullet') && !on.has('ordered') && !on.has('quote')) on.add('text');
466
- return on;
467
- }
468
-
469
- // The colour where the cursor is, or the first in the selection (text-colors.mjs); null without one.
470
- function colorAt(state: EditorView['state']): string | null {
471
- const type = state.schema.marks.textColor;
472
- const { from, to, empty, $from } = state.selection;
473
- if (empty) return type.isInSet(state.storedMarks ?? $from.marks())?.attrs.color ?? null;
474
- let color: string | null = null;
475
- state.doc.nodesBetween(from, to, (n) => void (color ??= type.isInSet(n.marks)?.attrs.color ?? null));
476
- return color;
477
- }
478
-
479
- /** Gives the selection one of the site's colours (text-colors.mjs), or takes its colour away (null). */
480
- export function paint(view: EditorView, color: string | null) {
481
- const { state } = view;
482
- const type = state.schema.marks.textColor;
483
- const { from, to } = state.selection;
484
- if (from === to) return view.focus();
485
- const tr = state.tr.removeMark(from, to, type);
486
- view.dispatch(color ? tr.addMark(from, to, type.create({ color })) : tr);
487
- view.focus();
488
- }
489
-
490
- // off: not there for the selection (the button is hidden).
491
- type Look = {
492
- icon: string;
493
- label: string;
494
- on: (state: EditorView['state']) => boolean;
495
- action: (button: HTMLElement) => void;
496
- off?: (state: EditorView['state']) => boolean;
497
- };
498
-
499
- // Whether a heading (H2) is selected, or a part of one.
500
- function inH2(state: EditorView['state']) {
501
- let found = false;
502
- const { from, to } = state.selection;
503
- state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level === 2));
504
- return found;
505
- }
506
-
507
- // When a word or more is selected: a row of icons over it, to make it bold, italic, a link, one of the site's colours
508
- // (a menu under the button, opened by `colors`), the page's H1 (`h1`), a heading or a subheading; the ones that apply to
509
- // the selection are marked. On a phone, where the phone's own menu is over the selection and its handles under it, the
510
- // row is at the bottom of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
511
- function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button: HTMLElement) => void) {
512
- return new Plugin({
513
- view(view) {
514
- const menu = document.body.appendChild(document.createElement('div'));
515
- menu.className = 'editor-selection-menu';
516
- menu.setAttribute('role', 'toolbar');
517
- menu.setAttribute('aria-label', t.text.selection);
518
- menu.hidden = true;
519
- const run = <T,>(command: { key: CmdKey<T> }, payload?: T) => void callCommand(command.key, payload)(ctx);
520
- const marked = (state: EditorView['state'], name: string) => activeOf(state).has(name);
521
- const heading = (state: EditorView['state'], level: number) => activeOf(state).has(`h${level}`);
522
- const looks: Look[] = [
523
- { icon: boldIcon, label: t.text.bold, on: (s) => marked(s, 'strong'), action: () => run(toggleStrongCommand) },
524
- { icon: italicIcon, label: t.text.italic, on: (s) => marked(s, 'emphasis'), action: () => run(toggleEmphasisCommand) },
525
- {
526
- icon: linkIcon,
527
- label: t.text.link,
528
- on: (s) => marked(s, 'link'),
529
- action: () => void editLink(view).then(() => update(view)),
530
- },
531
- {
532
- icon: paletteIcon,
533
- label: t.text.color,
534
- on: (s) => marked(s, 'textColor'),
535
- action: colors,
536
- off: sections ? inH2 : undefined,
537
- },
538
- ...(h1 ? [1, 2, 3] : [2, 3]).map((level) => ({
539
- icon: [h1Icon, h2Icon, h3Icon][level - 1],
540
- label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
541
- on: (s: EditorView['state']) => heading(s, level),
542
- action: () => (heading(view.state, level) ? run(turnIntoTextCommand) : run(wrapInHeadingCommand, level)),
543
- })),
544
- ];
545
- const buttons = looks.map((look) => {
546
- const b = menu.appendChild(document.createElement('button'));
547
- b.type = 'button';
548
- b.title = look.label;
549
- b.setAttribute('aria-label', look.label);
550
- b.innerHTML = look.icon.replace('<svg', '<svg width="20" height="20" fill="currentColor" aria-hidden="true"');
551
- if (look.action === colors) b.setAttribute('aria-haspopup', 'menu');
552
- const act = () => {
553
- look.action(b);
554
- update(view);
555
- // Again when "Gem ændringer" has come after the first change.
556
- setTimeout(follow, 100);
557
- };
558
- // The selection stays: no focus taken with the mouse, and a tap does not end the selection
559
- // (its click is the phone's, so the tap acts itself).
560
- b.addEventListener('mousedown', (e) => e.preventDefault());
561
- b.addEventListener('click', act);
562
- b.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
563
- b.addEventListener('touchend', (e) => {
564
- e.preventDefault();
565
- act();
566
- });
567
- return b;
568
- });
569
- function update(view: EditorView) {
570
- const { selection, doc } = view.state;
571
- const shown =
572
- view.hasFocus() &&
573
- selection instanceof TextSelection &&
574
- !selection.empty &&
575
- doc.textBetween(selection.from, selection.to).trim() !== '';
576
- menu.hidden = !shown;
577
- if (!shown) return;
578
- looks.forEach((look, i) => {
579
- buttons[i].hidden = !!look.off?.(view.state);
580
- buttons[i].setAttribute('aria-pressed', String(look.on(view.state)));
581
- });
582
- const { offsetWidth: width, offsetHeight: height } = menu;
583
- const screen = visualViewport ?? { offsetLeft: 0, offsetTop: 0, width: innerWidth, height: innerHeight };
584
- if (matchMedia('(pointer: coarse)').matches) {
585
- menu.style.left = `${screen.offsetLeft + (screen.width - width) / 2}px`;
586
- // Over "Gem ændringer" when it is there (FormFrame.tsx).
587
- const save = document.querySelector('.editor-save-bar')?.getBoundingClientRect().top ?? Infinity;
588
- menu.style.top = `${Math.min(screen.offsetTop + screen.height, save) - height - 12}px`;
589
- return;
590
- }
591
- const text = view.dom.getBoundingClientRect();
592
- const start = view.coordsAtPos(selection.from);
593
- const end = view.coordsAtPos(selection.to);
594
- const middle = start.top === end.top ? (start.left + end.right) / 2 : text.left + text.width / 2;
595
- // Over the selection; under it when the toolbar is there.
596
- const bar = document.querySelector('.editor-tools')?.getBoundingClientRect().bottom ?? 0;
597
- const over = start.top - height - 10;
598
- menu.style.left = `${Math.max(4, Math.min(middle - width / 2, innerWidth - width - 4))}px`;
599
- menu.style.top = `${over > bar ? over : end.bottom + 10}px`;
600
- }
601
- // Gone when the text is left (not for a tap on the menu itself, which keeps the focus).
602
- const refresh = () => setTimeout(() => update(view));
603
- // Where it is follows the page and the keyboard.
604
- const follow = () => menu.hidden || update(view);
605
- view.dom.addEventListener('blur', refresh);
606
- view.dom.addEventListener('focus', refresh);
607
- addEventListener('scroll', follow, { passive: true });
608
- visualViewport?.addEventListener('resize', follow);
609
- visualViewport?.addEventListener('scroll', follow);
610
- return {
611
- update,
612
- destroy: () => {
613
- view.dom.removeEventListener('blur', refresh);
614
- view.dom.removeEventListener('focus', refresh);
615
- removeEventListener('scroll', follow);
616
- visualViewport?.removeEventListener('resize', follow);
617
- visualViewport?.removeEventListener('scroll', follow);
618
- menu.remove();
619
- },
620
- };
621
- },
622
- });
623
- }
624
-
625
- // A block in the text as a card: the block as it looks on the site (its draw, src/apps/<app>/blocks/), in a thin frame;
626
- // for screen readers its name and what is in it (describe in blocks.ts). Tapping it opens a row of
627
- // icons on it to edit, hide or show, move and remove it. cards: how to draw the previews of the
628
- // text again, when what they show from elsewhere has changed.
629
- function blockCard(
630
- node: Node,
631
- view: EditorView,
632
- getPos: () => number | undefined,
633
- asker: () => BlockAsk | undefined,
634
- cards: Set<() => void>,
635
- ) {
636
- const ask = asker();
637
- const block = node.attrs.type === HTML ? htmlBlock : ask?.list.find((b) => b.type === node.attrs.type);
638
- const hidden = node.attrs.props.hidden === true;
639
- // A block gone from the site by itself (a notice whose time is over) is greyed like a hidden one.
640
- const over = !!block?.over?.(node.attrs.props);
641
- const dom = document.createElement('div');
642
- dom.className = hidden || over ? 'site-block site-block-hidden' : 'site-block';
643
- dom.contentEditable = 'false';
644
- const card = dom.appendChild(document.createElement('button'));
645
- card.type = 'button';
646
- card.className = 'site-block-card';
647
- const name = block?.label ?? node.attrs.type;
648
- card.setAttribute('aria-label', name);
649
- if (hidden) {
650
- // An eye struck through in the corner (for screen readers it says so in the description).
651
- const badge = card.appendChild(document.createElement('span'));
652
- badge.className = 'site-block-hidden-badge';
653
- badge.innerHTML = eyeSlashIcon.replace('<svg', '<svg width="18" height="18" fill="currentColor" aria-hidden="true"');
654
- }
655
- const shown = card.appendChild(document.createElement('div'));
656
- shown.className = ask?.data.page ? 'site-block-preview site-block-section not-prose' : 'site-block-preview not-prose';
657
- shown.setAttribute('aria-hidden', 'true');
658
- const root = createRoot(shown);
659
- const draw = () => {
660
- const ask = asker();
661
- if (!ask) return;
662
- const lines = block ? describe(block, node.attrs.props, ask.data) : [];
663
- card.setAttribute('aria-description', [hidden ? t.text.hiddenOnPage : '', ...lines].filter(Boolean).join('. '));
664
- // Without a picture of it, what is in it, in lines.
665
- const picture = preview(node.attrs.type, node.attrs.props, ask.data) ?? (
666
- <div className="site-block-lines">
667
- {lines.map((line, i) => (
668
- <span key={i}>{line}</span>
669
- ))}
670
- </div>
671
- );
672
- flushSync(() => root.render(picture));
673
- };
674
- draw();
675
- cards.add(draw);
676
-
677
- const at = () => {
678
- const pos = getPos();
679
- return pos === undefined ? null : { pos, $pos: view.state.doc.resolve(pos) };
680
- };
681
- // Where the card is at the top of the text, to move it.
682
- const top = () => {
683
- const pos = getPos();
684
- return pos === undefined ? null : view.state.doc.resolve(pos).index(0);
685
- };
686
- const edit = async () => {
687
- const props = await asker()?.edit(node.attrs.type, node.attrs.props);
688
- const here = at();
689
- // The block's fields as changed; still hidden if it was.
690
- if (props && here)
691
- view.dispatch(
692
- view.state.tr.setNodeMarkup(here.pos, undefined, { ...node.attrs, props: hidden ? { ...props, hidden: true } : props }),
693
- );
694
- };
695
- const toggle = () => {
696
- const here = at();
697
- if (!here) return;
698
- const { hidden: _, ...props } = node.attrs.props;
699
- view.dispatch(view.state.tr.setNodeMarkup(here.pos, undefined, { ...node.attrs, props: hidden ? props : { ...props, hidden: true } }));
700
- };
701
- const remove = () => {
702
- const here = at();
703
- if (here) view.dispatch(view.state.tr.delete(here.pos, here.pos + node.nodeSize));
704
- };
705
-
706
- // The choices that can be made here: no "Ret" without fields (but to where it is edited instead,
707
- // Block.edit), no move past the start or end. By the template (collections.ts): a locked block is
708
- // only edited, and the last shown one of a block the page must have cannot be hidden or removed.
709
- const close = cardMenu(view, dom, card, name, () => {
710
- const i = top();
711
- const rules = asker()?.template;
712
- const type = node.attrs.type;
713
- const locked = !!rules?.locked?.includes(type);
714
- const shownOfType = blocksOf(view.state.doc).filter((b) => b.type === type && b.hidden === false).length;
715
- // (Not written with "!" before "hidden", which Tailwind would take for a class for the site.)
716
- const last = hidden === false && !!rules?.required?.includes(type) && shownOfType <= 1;
717
- const stays = locked || last;
718
- return [
719
- block?.fields.length
720
- ? { icon: pencilIcon, label: t.common.edit, action: edit }
721
- : block?.edit && { icon: pencilIcon, label: block.edit.text, action: () => leaveTo(`#${block.edit!.to}`) },
722
- block?.shown || (stays && hidden === false)
723
- ? undefined
724
- : { icon: hidden ? eyeIcon : eyeSlashIcon, label: hidden ? t.text.showOnPage : t.text.hideOnPage, action: toggle },
725
- ...(locked ? [] : moves(view, i, rules?.locked)),
726
- stays ? undefined : { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
727
- ];
728
- });
729
- // A changed block is drawn anew (update false); taps on the card and its menu are not ProseMirror's.
730
- return {
731
- dom,
732
- update: () => false,
733
- stopEvent: () => true,
734
- ignoreMutation: () => true,
735
- destroy: () => {
736
- close();
737
- cards.delete(draw);
738
- // Not while React may be drawing (the editor goes with its form).
739
- queueMicrotask(() => root.unmount());
740
- },
741
- };
742
- }
743
-
744
- export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
745
- { value, onChange, addImage, blocks, sheet: sheetProp, short, h1, looks, sections },
746
- ref,
747
- ) {
748
- const spaced = !!blocks;
749
- const root = useRef<HTMLDivElement>(null);
750
- const toolbar = useRef<HTMLDivElement>(null);
751
- const editor = useRef<Editor | null>(null);
752
- const changed = useRef(onChange);
753
- changed.current = onChange;
754
- const sheeted = useRef(blocks ?? sheetProp);
755
- sheeted.current = blocks ?? sheetProp;
756
- const looked = useRef(looks);
757
- looked.current = looks;
758
- // What applies where the cursor is (activeOf), marked in the toolbar.
759
- const [active, setActive] = useState<string[]>([]);
760
- // The colour button over the selection, while its menu with the colours is open.
761
- const [colorsAt, setColorsAt] = useState<HTMLElement | null>(null);
762
- useEffect(() => {
763
- colorsAt?.setAttribute('aria-expanded', 'true');
764
- return () => colorsAt?.setAttribute('aria-expanded', 'false');
765
- }, [colorsAt]);
766
- // The previews of the blocks, drawn again when what they show from elsewhere changes: the lists,
767
- // the testimonials, the rest of the site, the page's address or a fixed page's other fields.
768
- const cards = useRef(new Set<() => void>());
769
- const data = blocks?.data;
770
- useEffect(
771
- () => queueMicrotask(() => cards.current.forEach((draw) => draw())),
772
- [data?.lists, data?.testimonials, data?.site, data?.path, data?.page?.values],
773
- );
774
-
775
- useEffect(() => {
776
- let cancelled = false;
777
- // Tell the form as soon as the text changes (Milkdown's own listener waits 200 ms); not for the
778
- // free lines put in as the text is loaded.
779
- let loaded = false;
780
- const marking = $prose(
781
- () =>
782
- new Plugin({
783
- view: () => ({
784
- update: (view) => {
785
- const now = [...activeOf(view.state)].sort();
786
- setActive((was) => (was.join() === now.join() ? was : now));
787
- },
788
- }),
789
- }),
790
- );
791
- const onEdit = $prose(
792
- () => new Plugin({ view: () => ({ update: (view, prev) => void (!loaded || view.state.doc.eq(prev.doc) || changed.current()) }) }),
793
- );
794
- const selecting = $prose((ctx) => selectionMenu(ctx, !!h1, !!sections, (button) => setColorsAt((open) => (open ? null : button))));
795
- const pictureView = $view(imageSchema.node, () => (node, view, getPos) => pictureCard(node, view, getPos, () => sheeted.current));
796
- const cardView = $view(
797
- siteBlockSchema.node,
798
- () => (node, view, getPos) => blockCard(node, view, getPos, () => sheeted.current, cards.current),
799
- );
800
- // The look of each part on the site, as data-look on it.
801
- const looking = $prose(
802
- () =>
803
- new Plugin({
804
- props: {
805
- decorations: (state) => {
806
- const looks = looked.current;
807
- if (!looks) return null;
808
- const nodes: { node: Node; pos: number }[] = [];
809
- state.doc.forEach((node, pos) => void nodes.push({ node, pos }));
810
- const marks = looks(nodes.map(({ node }) => lookItem(node)));
811
- return DecorationSet.create(
812
- state.doc,
813
- nodes.flatMap(({ node, pos }, i) =>
814
- marks[i] ? [Decoration.node(pos, pos + node.nodeSize, { 'data-look': marks[i]! })] : [],
815
- ),
816
- );
817
- },
818
- },
819
- }),
820
- );
821
- const made = makeEditor(root.current, value)
822
- .use(cardView)
823
- .use(pictureView)
824
- .use(selecting)
825
- .use(cardDeletes)
826
- .use(marking)
827
- .use(onEdit)
828
- .use(looking)
829
- .use(gapHere)
830
- .use(history)
831
- .use(clipboard);
832
- (spaced ? made.use(spacing) : made).create().then((created) => {
833
- if (cancelled) return created.destroy();
834
- editor.current = created;
835
- // The free lines between the blocks of the text as it is loaded, and the cursor in the first,
836
- // so a key pressed before the text is tapped does not replace the first block.
837
- if (spaced)
838
- created.action((ctx) => {
839
- const view = ctx.get(editorViewCtx);
840
- view.dispatch(view.state.tr.setMeta('addToHistory', false));
841
- view.dispatch(view.state.tr.setSelection(Selection.atStart(view.state.doc)).setMeta('addToHistory', false));
842
- });
843
- loaded = true;
844
- });
845
- return () => {
846
- cancelled = true;
847
- editor.current?.destroy();
848
- editor.current = null;
849
- };
850
- // The editor is made once per file; the parent gives each file its own key.
851
- }, []);
852
-
853
- useImperativeHandle(ref, () => ({
854
- markdown: () => editor.current?.action(markdownOf) ?? null,
855
- parts: () => editor.current?.action(partsOf) ?? null,
856
- }));
857
-
858
- function run<T>(command: { key: CmdKey<T> }, payload?: T) {
859
- editor.current?.action(callCommand(command.key, payload));
860
- }
861
-
862
- // Makes the text where the cursor is a heading ('h1', 'h2', 'h3'), a paragraph ('text'), a list
863
- // ('bullet', 'ordered') or a quote ('quote'), whatever it is now: a list becomes the other kind of
864
- // list, and the text of a list or a quote comes out of it to be something else.
865
- function setKind(kind: string) {
866
- editor.current?.action((ctx) => {
867
- const view = ctx.get(editorViewCtx);
868
- const { schema, selection } = view.state;
869
- const is = (k: string) => activeOf(view.state).has(k);
870
- const outOfLists = () => {
871
- for (let i = 0; i < 10 && (is('bullet') || is('ordered')) && liftListItem(schema.nodes.list_item)(view.state, view.dispatch); i++);
872
- };
873
- const outOfQuotes = () => {
874
- for (let i = 0; i < 10 && is('quote') && lift(view.state, view.dispatch); i++);
875
- };
876
- if (kind === 'quote') {
877
- if (!is('quote')) {
878
- outOfLists();
879
- if (is('h1') || is('h2') || is('h3')) callCommand(turnIntoTextCommand.key)(ctx);
880
- callCommand(wrapInBlockquoteCommand.key)(ctx);
881
- }
882
- return view.focus();
883
- }
884
- if (kind === 'bullet' || kind === 'ordered') {
885
- const list = schema.nodes[kind === 'bullet' ? 'bullet_list' : 'ordered_list'];
886
- const { $from } = selection;
887
- for (let d = $from.depth; d > 0; d--) {
888
- const node = $from.node(d);
889
- if (node.type.name !== 'bullet_list' && node.type.name !== 'ordered_list') continue;
890
- if (node.type !== list) view.dispatch(view.state.tr.setNodeMarkup($from.before(d), list, { spread: node.attrs.spread }));
891
- return view.focus();
892
- }
893
- outOfQuotes();
894
- if (is('h1') || is('h2') || is('h3')) callCommand(turnIntoTextCommand.key)(ctx);
895
- callCommand((kind === 'bullet' ? wrapInBulletListCommand : wrapInOrderedListCommand).key)(ctx);
896
- return view.focus();
897
- }
898
- outOfLists();
899
- outOfQuotes();
900
- if (kind === 'text') callCommand(turnIntoTextCommand.key)(ctx);
901
- else callCommand(wrapInHeadingCommand.key, Number(kind.slice(1)))(ctx);
902
- view.focus();
903
- });
904
- }
905
-
906
- function link() {
907
- editor.current?.action((ctx) => void editLink(ctx.get(editorViewCtx)));
908
- }
909
-
910
- function color(name: string | null) {
911
- editor.current?.action((ctx) => paint(ctx.get(editorViewCtx), name));
912
- }
913
-
914
- // A new picture from the gallery, then the sheet with it and what it shows, where it can still be
915
- // changed or left out (the browser's own box for what it shows without a sheet).
916
- async function image() {
917
- const src = await addImage!();
918
- if (!src) return;
919
- const sheet = blocks ?? sheetProp;
920
- const picked = await sheet?.edit(IMAGE, { src, alt: '' }, true);
921
- if (picked) run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
922
- }
923
-
924
- // Puts the chosen block where the cursor is (a paragraph is split around it), or after the block
925
- // that is selected. With no text after it, an empty paragraph follows, where the cursor goes, so
926
- // the next text or block comes after it instead of replacing it.
927
- async function block() {
928
- // The blocks and pictures in the text now, for how many of a type there may be (the template).
929
- let have: string[] = [];
930
- editor.current?.action((ctx) => {
931
- const doc = ctx.get(editorViewCtx).state.doc;
932
- have = blocksOf(doc).map((b) => b.type);
933
- doc.descendants((n) => void (n.type.name === 'image' && have.push(IMAGE)));
934
- });
935
- const chosen = await blocks!.choose({ image: !!addImage, have });
936
- if (!chosen) return;
937
- if (chosen.type === IMAGE) return image();
938
- editor.current?.action((ctx) => {
939
- const view = ctx.get(editorViewCtx);
940
- const { state } = view;
941
- const node = siteBlockSchema.type(ctx).create({ type: chosen.type, props: chosen.props });
942
- let tr = state.tr;
943
- // Where the new block ends, when the cursor must be moved past it (-1: it is in text already).
944
- let end = -1;
945
- if (state.selection instanceof NodeSelection) {
946
- tr = tr.insert(state.selection.to, node);
947
- end = state.selection.to + node.nodeSize;
948
- } else {
949
- tr = tr.replaceSelectionWith(node);
950
- if (tr.selection instanceof NodeSelection) end = tr.selection.to;
951
- }
952
- if (end >= 0) {
953
- if (!tr.doc.resolve(end).nodeAfter?.isTextblock) tr = tr.insert(end, state.schema.nodes.paragraph.create());
954
- tr = tr.setSelection(TextSelection.create(tr.doc, end + 1));
955
- }
956
- // A section whose heading is written above its card gets one, its name, selected to write over.
957
- const chosenBlock = blocks!.list.find((b) => b.type === chosen.type);
958
- if (chosenBlock?.lead?.heading) {
959
- let at = -1;
960
- tr.doc.forEach((n, offset) => void (n === node && (at = offset)));
961
- if (at >= 0) {
962
- tr = tr.insert(at, state.schema.nodes.heading.create({ level: 2 }, state.schema.text(chosenBlock.label)));
963
- tr = tr.setSelection(TextSelection.create(tr.doc, at + 1, at + 1 + chosenBlock.label.length));
964
- }
965
- }
966
- view.dispatch(tr.scrollIntoView());
967
- view.focus();
968
- });
969
- }
970
-
971
- const tools: { label: string; icon: typeof TextB; action: () => void; on?: string }[] = [
972
- { label: t.text.bold, icon: TextB, action: () => run(toggleStrongCommand), on: 'strong' },
973
- { label: t.text.italic, icon: TextItalic, action: () => run(toggleEmphasisCommand), on: 'emphasis' },
974
- { label: t.text.link, icon: LinkSimple, action: link, on: 'link' },
975
- ...(h1 ? [{ label: t.text.pageHeading, icon: TextHOne, action: () => setKind('h1'), on: 'h1' }] : []),
976
- { label: t.text.heading, icon: TextHTwo, action: () => setKind('h2'), on: 'h2' },
977
- { label: t.text.subheading, icon: TextHThree, action: () => setKind('h3'), on: 'h3' },
978
- { label: t.text.paragraph, icon: TextT, action: () => setKind('text'), on: 'text' },
979
- { label: t.text.bulletList, icon: ListBullets, action: () => setKind('bullet'), on: 'bullet' },
980
- { label: t.text.orderedList, icon: ListNumbers, action: () => setKind('ordered'), on: 'ordered' },
981
- { label: t.text.quote, icon: Quotes, action: () => setKind('quote'), on: 'quote' },
982
- // A picture and the blocks are put in with "+" (a sheet with them all); without blocks (a blog
983
- // post) a picture has its own button.
984
- ...(blocks
985
- ? [{ label: t.text.insert, icon: Plus, action: block }]
986
- : addImage
987
- ? [{ label: t.text.image, icon: Image, action: image }]
988
- : []),
989
- { label: t.text.undo, icon: ArrowUUpLeft, action: () => run(undoCommand) },
990
- { label: t.text.redo, icon: ArrowUUpRight, action: () => run(redoCommand) },
991
- ];
992
-
993
- // When the toolbar is too short for all its buttons, the kinds of text (H1, H2, H3, T, the bulleted and
994
- // the numbered list and the quote) are one button: the one where the cursor is, with an arrow, and
995
- // the others under it.
996
- const kinds = tools.filter((t) => ['h1', 'h2', 'h3', 'text', 'bullet', 'ordered', 'quote'].includes(t.on ?? ''));
997
- const kind = kinds.find((t) => active.includes(t.on!));
998
- const shown = kind ?? kinds.find((t) => t.on === 'text')!;
999
- const [narrow, setNarrow] = useState(false);
1000
- // The button that opened the menu with the kinds of text, while it is open.
1001
- const [kindsAt, setKindsAt] = useState<HTMLElement | null>(null);
1002
- useEffect(() => {
1003
- const bar = toolbar.current!;
1004
- // A button is 2.5rem, with 0.125rem between them and 0.375rem around them, and each of the lines
1005
- // between the groups 0.5rem (the classes below).
1006
- const fits = () => setNarrow(bar.clientWidth < tools.length * 42 + 10 + 3 * 10);
1007
- const watch = new ResizeObserver(fits);
1008
- watch.observe(bar);
1009
- return () => watch.disconnect();
1010
- }, [tools.length]);
1011
- const tool = ({ label, icon, action, on }: (typeof tools)[number]) => (
1012
- <ToolbarButton key={label} icon={icon} label={label} selected={on ? active.includes(on) : undefined} onClick={action} />
1013
- );
1014
-
1015
- // The lines between the marks on the text, the kinds of text, "+" (or "Billede") and "Fortryd".
1016
- const lineBefore = (item: (typeof tools)[number]) =>
1017
- item === kinds[0] || item.icon === Plus || item.icon === Image || item.icon === ArrowUUpLeft;
1018
- // A button of the toolbar; when it is too short, the kinds of text are one button (at the first), with a menu.
1019
- const button = (item: (typeof tools)[number]) =>
1020
- !narrow || !kinds.includes(item) ? (
1021
- tool(item)
1022
- ) : item === kinds[0] ? (
1023
- // Not marked: it always shows the kind there is (the menu under it marks it).
1024
- <ToolbarButton
1025
- key="kinds"
1026
- icon={shown.icon}
1027
- label={t.text.kindIs(shown.label)}
1028
- menu={!!kindsAt}
1029
- onClick={(e) => setKindsAt(kindsAt ? null : e.currentTarget)}
1030
- />
1031
- ) : null;
1032
-
1033
- return (
1034
- <div className="rounded-panel border border-line bg-surface">
1035
- {/* Stays at the top while scrolling through the text, a little under the app's top bar (Screen.tsx,
1036
- 4rem and its line); on a phone one row that scrolls sideways. The buttons are centred, but
1037
- start at the left edge when they do not fit (safe), so none is out of reach. */}
1038
- <Toolbar
1039
- ref={toolbar}
1040
- label={t.text.tools}
1041
- className="editor-tools sticky top-[calc(4.5rem+1px+env(safe-area-inset-top))] z-10 justify-center-safe overflow-x-auto rounded-t-panel border-b border-line md:flex-wrap"
1042
- >
1043
- {tools.map((tool) => (
1044
- <ReactFragment key={tool.label}>
1045
- {lineBefore(tool) && <span aria-hidden="true" className="mx-1 my-2 w-px shrink-0 self-stretch bg-line" />}
1046
- {button(tool)}
1047
- </ReactFragment>
1048
- ))}
1049
- </Toolbar>
1050
- {/* The site's colours (text-colors.mjs), each named in its colour, as it is in the text (its class), and "Ingen
1051
- farve" in the text's own. */}
1052
- <Menu anchor={colorsAt} label={t.text.color} onClose={() => setColorsAt(null)}>
1053
- {TEXT_COLORS.map((name) => (
1054
- <MenuItem key={name} icon={TextAa} selected={active.includes(colorClass(name))} onClick={() => color(name)}>
1055
- <span className={colorClass(name)}>{t.text.colors[name as keyof typeof t.text.colors]}</span>
1056
- </MenuItem>
1057
- ))}
1058
- <MenuItem icon={TextAa} selected={!active.includes('textColor')} onClick={() => color(null)}>
1059
- <span className="text-ink">{t.text.noColor}</span>
1060
- </MenuItem>
1061
- </Menu>
1062
- <Menu anchor={kindsAt} label={t.text.kind} onClose={() => setKindsAt(null)}>
1063
- {kinds.map(({ label, icon, action, on }) => (
1064
- <MenuItem key={label} icon={icon} selected={active.includes(on!)} onClick={action}>
1065
- {label}
1066
- </MenuItem>
1067
- ))}
1068
- </Menu>
1069
- <div
1070
- ref={root}
1071
- className={`editor-text prose max-w-none px-5 py-4 md:px-8 ${short ? 'editor-text-short' : ''} ${spaced ? 'editor-spaced' : ''}`}
1072
- />
1073
- </div>
1074
- );
1075
- });
1
+ // The text of a page or blog post: Milkdown (packages/editor/src/markdown.mjs) with a small toolbar.
2
+ // The text is shown in the site's own style (the "prose" classes of the pages). On pages the site's
3
+ // blocks (src/apps/<app>/blocks/) can be put in the text with a button; each is a card with the block as it looks
4
+ // on the site, which opens a menu (Ret, hide on the site, move, Fjern), with a free line kept between
5
+ // two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
6
+ // and work the same.
7
+ import { forwardRef, Fragment as ReactFragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
8
+ import { editorViewCtx, type Editor } from '@milkdown/kit/core';
9
+ import type { CmdKey } from '@milkdown/kit/core';
10
+ import type { Ctx } from '@milkdown/kit/ctx';
11
+ import {
12
+ imageSchema,
13
+ insertImageCommand,
14
+ toggleEmphasisCommand,
15
+ toggleStrongCommand,
16
+ turnIntoTextCommand,
17
+ wrapInBlockquoteCommand,
18
+ wrapInBulletListCommand,
19
+ wrapInHeadingCommand,
20
+ wrapInOrderedListCommand,
21
+ } from '@milkdown/kit/preset/commonmark';
22
+ import { history, redoCommand, undoCommand } from '@milkdown/kit/plugin/history';
23
+ import { clipboard } from '@milkdown/kit/plugin/clipboard';
24
+ import { $prose, $view, callCommand } from '@milkdown/kit/utils';
25
+ import { Fragment } from '@milkdown/kit/prose/model';
26
+ import type { Mark, Node } from '@milkdown/kit/prose/model';
27
+ import { Decoration, DecorationSet, type EditorView } from '@milkdown/kit/prose/view';
28
+ import { NodeSelection, Plugin, Selection, TextSelection, type EditorState } from '@milkdown/kit/prose/state';
29
+ import { liftListItem } from '@milkdown/kit/prose/schema-list';
30
+ import { lift } from '@milkdown/kit/prose/commands';
31
+ import {
32
+ ArrowUUpLeft,
33
+ ArrowUUpRight,
34
+ Image,
35
+ LinkSimple,
36
+ ListBullets,
37
+ ListNumbers,
38
+ Plus,
39
+ Quotes,
40
+ TextAa,
41
+ TextB,
42
+ TextHThree,
43
+ TextHOne,
44
+ TextHTwo,
45
+ TextItalic,
46
+ TextT,
47
+ } from '@phosphor-icons/react';
48
+ import { HTML, makeEditor, markdownOf, partsOf, siteBlockSchema, spacing } from './markdown.mjs';
49
+ import { TEXT_COLORS, colorClass } from '../lib/text-colors.mjs';
50
+ import { IMAGE, type BlockAsk } from './BlockDialog';
51
+ import { askLink } from './LinkSheet';
52
+ import { Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
53
+ import { shownAt } from './images';
54
+ import { cropStyle, joinPicture, splitPicture } from '../lib/crop.mjs';
55
+ import { describe, htmlBlock, type BlockProps } from './blocks';
56
+ import { preview } from './registry';
57
+ import { leaveTo } from './FormFrame';
58
+ import { createRoot } from 'react-dom/client';
59
+ import { flushSync } from 'react-dom';
60
+ import { t } from './i18n';
61
+ import { motionTiming } from '../lib/motion';
62
+ // The icons of the card's menu (Phosphor, as everywhere in the editor), as SVG: the card is not React.
63
+ import pencilIcon from '@phosphor-icons/core/assets/regular/pencil-simple.svg?raw';
64
+ import eyeIcon from '@phosphor-icons/core/assets/regular/eye.svg?raw';
65
+ import eyeSlashIcon from '@phosphor-icons/core/assets/regular/eye-slash.svg?raw';
66
+ import arrowUpIcon from '@phosphor-icons/core/assets/regular/arrow-up.svg?raw';
67
+ import arrowDownIcon from '@phosphor-icons/core/assets/regular/arrow-down.svg?raw';
68
+ import trashIcon from '@phosphor-icons/core/assets/regular/trash.svg?raw';
69
+ import boldIcon from '@phosphor-icons/core/assets/regular/text-b.svg?raw';
70
+ import italicIcon from '@phosphor-icons/core/assets/regular/text-italic.svg?raw';
71
+ import linkIcon from '@phosphor-icons/core/assets/regular/link-simple.svg?raw';
72
+ import h1Icon from '@phosphor-icons/core/assets/regular/text-h-one.svg?raw';
73
+ import h2Icon from '@phosphor-icons/core/assets/regular/text-h-two.svg?raw';
74
+ import h3Icon from '@phosphor-icons/core/assets/regular/text-h-three.svg?raw';
75
+ import paletteIcon from '@phosphor-icons/core/assets/regular/palette.svg?raw';
76
+ // ProseMirror's own styles: keep its invisible helpers (around pictures, at the end of lines) out of the layout.
77
+ import '@milkdown/kit/prose/view/style/prosemirror.css';
78
+
79
+ // parts: the text in parts, for a fixed page's sections (partsOf in markdown.mjs).
80
+ export type MarkdownHandle = { markdown: () => string | null; parts: () => ReturnType<typeof partsOf> | null };
81
+
82
+ // addImage: asks for a picture (the gallery, Media.tsx); its address on the site. Its description is
83
+ // asked in the sheet. Without it there is no picture button. blocks: asks which block to put in and its fields (BlockDialog);
84
+ // without it there is no block button; with it a free line is kept between two blocks, and before
85
+ // and after them (spacing in markdown.mjs). short: a smaller field, for a short text. h1: the page's own heading, "Overskrift
86
+ // (H1)", can be chosen too (a page's text, which has its H1 at the top; a blog post's is its title).
87
+ type Props = {
88
+ value: string;
89
+ onChange: () => void;
90
+ addImage?: () => Promise<string | null>;
91
+ blocks?: BlockAsk;
92
+ /** The sheet a picture or HTML is changed in, when there are no blocks (a blog post). */
93
+ sheet?: BlockAsk;
94
+ short?: boolean;
95
+ h1?: boolean;
96
+ /** How each part of the text looks on the site (looksOf in sections.mjs), shown so (editor.css). */
97
+ looks?: (items: LookItem[]) => (string | null)[];
98
+ /** The headings (H2) are the names of sections (a fixed page's, the front page's: sections.mjs), which the site
99
+ * draws as text: no colour for them. */
100
+ sections?: boolean;
101
+ };
102
+
103
+ // A part of the text at the top level, for `looks`.
104
+ export type LookItem = { kind: 'block'; type: string } | { kind: 'heading' | 'gap' | 'other' } | { kind: 'p'; image: boolean };
105
+
106
+ const lookItem = (node: Node): LookItem =>
107
+ node.type.name === 'siteBlock'
108
+ ? { kind: 'block', type: node.attrs.type }
109
+ : node.type.name === 'heading' && node.attrs.level === 2
110
+ ? { kind: 'heading' }
111
+ : node.type.name !== 'paragraph'
112
+ ? { kind: 'other' }
113
+ : node.content.size === 0
114
+ ? { kind: 'gap' }
115
+ : { kind: 'p', image: node.content.content.some((n) => n.type.name === 'image') };
116
+
117
+ // An empty paragraph: a free line between two blocks, which the arrows on a card skip.
118
+ const isGap = (node: Node) => node.type.name === 'paragraph' && node.content.size === 0;
119
+
120
+ // A card: a block, or a picture (in a paragraph of its own).
121
+ const isCard = (node: Node | null | undefined) => !!node && (node.type.name === 'siteBlock' || node.type.name === 'image');
122
+
123
+ // Whether deleting back (or forward) from the cursor reaches a card: one is selected, right before
124
+ // (after) the cursor, or before (after) the text the cursor is at the start (end) of.
125
+ function nextToCard(state: EditorState, back: boolean) {
126
+ const { selection } = state;
127
+ if (selection instanceof NodeSelection) return isCard(selection.node);
128
+ if (!selection.empty) return false;
129
+ const $pos = selection.$from;
130
+ if (isCard(back ? $pos.nodeBefore : $pos.nodeAfter)) return true;
131
+ if ($pos.parentOffset !== (back ? 0 : $pos.parent.content.size)) return false;
132
+ const next = $pos.node(-1).maybeChild($pos.index(-1) + (back ? -1 : 1));
133
+ return isCard(next) || isCard(back ? next?.lastChild : next?.firstChild);
134
+ }
135
+
136
+ // Deleting next to a card is ProseMirror's, as with a hardware keyboard (the card is selected, then
137
+ // removed). The keyboards of phones delete without a key (only beforeinput), and the browser then
138
+ // deleted the card, or what is in it, without ProseMirror knowing.
139
+ const cardDeletes = $prose(
140
+ () =>
141
+ new Plugin({
142
+ props: {
143
+ handleDOMEvents: {
144
+ beforeinput: (view, event) => {
145
+ const back = /^delete\w*Backward$/.test(event.inputType);
146
+ if (!back && !/^delete\w*Forward$/.test(event.inputType)) return false;
147
+ if (!nextToCard(view.state, back)) return false;
148
+ event.preventDefault();
149
+ const key = new KeyboardEvent('keydown', { key: back ? 'Backspace' : 'Delete', keyCode: back ? 8 : 46 });
150
+ view.someProp('handleKeyDown', (f) => f(view, key));
151
+ return true;
152
+ },
153
+ },
154
+ },
155
+ }),
156
+ );
157
+
158
+ // The free line the cursor is in, which opens up to write in (editor.css; the others are thin).
159
+ const gapHere = $prose(
160
+ () =>
161
+ new Plugin({
162
+ props: {
163
+ decorations: ({ doc, selection: { $from, empty } }) =>
164
+ empty && $from.depth === 1 && isGap($from.parent)
165
+ ? DecorationSet.create(doc, [Decoration.node($from.before(), $from.after(), { class: 'editor-gap-here' })])
166
+ : null,
167
+ },
168
+ }),
169
+ );
170
+
171
+ // The menu of the card that is open, if any: one at a time; closed by a tap elsewhere or Escape.
172
+ let closeOpenMenu: (() => void) | null = null;
173
+
174
+ // action: keyboard, whether the choice was made with the keyboard (Enter or Space).
175
+ type CardItem = { icon: string; label: string; action: (keyboard: boolean) => void; danger?: boolean };
176
+
177
+ // The part of the text at the top level before or after the one at index i (a block, or a paragraph
178
+ // with a picture), skipping the free lines between; null at the start or end.
179
+ function neighbour(doc: Node, i: number, by: -1 | 1) {
180
+ let j = i + by;
181
+ while (doc.maybeChild(j) && isGap(doc.child(j))) j += by;
182
+ return doc.maybeChild(j) ? j : null;
183
+ }
184
+
185
+ // The element of the part at the top level at index k.
186
+ const partDOM = (view: EditorView, k: number) => view.nodeDOM(view.state.doc.resolve(0).posAtIndex(k)) as HTMLElement | null;
187
+
188
+ // Swaps the part at the top level at index i with the one before or after it; free lines between
189
+ // stay. The parts glide from where they were to where they are now. Returns the moved part's new
190
+ // index, or null when it cannot move.
191
+ function move(view: EditorView, i: number, by: -1 | 1): number | null {
192
+ const { doc } = view.state;
193
+ const j = neighbour(doc, i, by);
194
+ if (j === null) return null;
195
+ const node = doc.child(i);
196
+ const [first, last] = by < 0 ? [j, i] : [i, j];
197
+ const nodes: Node[] = [];
198
+ for (let k = first; k <= last; k++) nodes.push(doc.child(k));
199
+ // Where each part is now, to let it glide from there; by the node, as the text may be tidied after
200
+ // the move (a section's heading goes with its card).
201
+ const tops = new Map<Node, number>();
202
+ doc.forEach((child, _, k) => {
203
+ const top = partDOM(view, k)?.getBoundingClientRect().top;
204
+ if (top !== undefined) tops.set(child, top);
205
+ });
206
+ const $start = doc.resolve(0);
207
+ const from = $start.posAtIndex(first);
208
+ const to = $start.posAtIndex(last) + doc.child(last).nodeSize;
209
+ const tr = view.state.tr.replaceWith(from, to, Fragment.from([nodes[nodes.length - 1], ...nodes.slice(1, -1), nodes[0]]));
210
+ // The cursor goes after the moved part, not on a block, which the next key would replace.
211
+ view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(by < 0 ? from + node.nodeSize : to))));
212
+ let at = -1;
213
+ view.state.doc.forEach((child, _, k) => {
214
+ if (child === node) at = k;
215
+ const was = tops.get(child);
216
+ const el = partDOM(view, k);
217
+ const dy = was === undefined || !el ? 0 : was - el.getBoundingClientRect().top;
218
+ if (el && Math.abs(dy) > 1 && !matchMedia('(prefers-reduced-motion: reduce)').matches)
219
+ el.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], motionTiming('spatial'));
220
+ });
221
+ if (at < 0) return null;
222
+ // Where it went, in view (one step: past one block, paragraph, heading or picture).
223
+ const moved = partDOM(view, at);
224
+ requestAnimationFrame(() => moved?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
225
+ return at;
226
+ }
227
+
228
+ // Opens the menu of the card at the top level at index k again (after it moved, it is a new card).
229
+ type CardHost = HTMLElement & { openMenu?: (keyboard: boolean, focus?: string) => void };
230
+ function reopen(view: EditorView, k: number, keyboard: boolean, label: string) {
231
+ const el = partDOM(view, k);
232
+ const host = (el?.matches('.site-block') ? el : el?.querySelector('.site-block')) as CardHost | null;
233
+ host?.openMenu?.(keyboard, label);
234
+ }
235
+
236
+ // The blocks at the top level of the text, in order: their type and whether they are hidden.
237
+ const blocksOf = (doc: Node) => {
238
+ const blocks: { type: string; hidden: boolean }[] = [];
239
+ doc.forEach((n) => void (n.type.name === 'siteBlock' && blocks.push({ type: n.attrs.type, hidden: n.attrs.props?.hidden === true })));
240
+ return blocks;
241
+ };
242
+
243
+ // "Flyt op" and "Flyt ned" for the part at the top level at index i, where it can be moved: not past
244
+ // the start or end, nor past a block the template locks (locked: their types). The menu stays open on
245
+ // the moved card, so it can be moved again at once.
246
+ const moves = (view: EditorView, i: number | null, locked: string[] = []): CardItem[] => {
247
+ if (i === null) return [];
248
+ const step = (by: -1 | 1) => (keyboard: boolean) => {
249
+ const at = move(view, i, by);
250
+ if (at !== null) reopen(view, at, keyboard, by < 0 ? t.common.moveUp : t.common.moveDown);
251
+ };
252
+ const can = (by: -1 | 1) => {
253
+ const j = neighbour(view.state.doc, i, by);
254
+ const next = j === null ? null : view.state.doc.child(j);
255
+ return !!next && !(next.type.name === 'siteBlock' && locked.includes(next.attrs.type));
256
+ };
257
+ return [
258
+ ...(can(-1) ? [{ icon: arrowUpIcon, label: t.common.moveUp, action: step(-1) }] : []),
259
+ ...(can(1) ? [{ icon: arrowDownIcon, label: t.common.moveDown, action: step(1) }] : []),
260
+ ];
261
+ };
262
+
263
+ // The row of icons on a card (a block or a picture) in the text, opened by tapping it: items, as
264
+ // they are when it opens. Returns how to close it.
265
+ function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: string, items: () => (CardItem | undefined | '')[]) {
266
+ let menu: HTMLElement | null = null;
267
+ function close() {
268
+ menu?.remove();
269
+ menu = null;
270
+ card.setAttribute('aria-expanded', 'false');
271
+ document.removeEventListener('pointerdown', outside, true);
272
+ document.removeEventListener('keydown', escape, true);
273
+ if (closeOpenMenu === close) closeOpenMenu = null;
274
+ }
275
+ const outside = (e: Event) => void (dom.contains(e.target as globalThis.Node) || close());
276
+ const escape = (e: KeyboardEvent) => {
277
+ if (e.key !== 'Escape') return;
278
+ close();
279
+ card.focus();
280
+ };
281
+ // keyboard: opened with Enter or Space, so a choice gets the focus: the first, or the one named
282
+ // `focus` (the arrow used to move the card, when it opens again on the moved card).
283
+ function open(keyboard: boolean, focus?: string) {
284
+ closeOpenMenu?.();
285
+ menu = dom.appendChild(document.createElement('div'));
286
+ menu.className = 'site-block-menu';
287
+ menu.setAttribute('role', 'menu');
288
+ menu.setAttribute('aria-label', name);
289
+ for (const it of items()) {
290
+ if (!it) continue;
291
+ const b = menu.appendChild(document.createElement('button'));
292
+ b.type = 'button';
293
+ b.setAttribute('role', 'menuitem');
294
+ b.setAttribute('aria-label', it.label);
295
+ b.title = it.label;
296
+ b.innerHTML = it.icon.replace('<svg', '<svg width="20" height="20" fill="currentColor" aria-hidden="true"');
297
+ if (it.danger) b.className = 'site-block-danger';
298
+ b.addEventListener('click', (e) => {
299
+ close();
300
+ it.action(e.detail === 0);
301
+ });
302
+ }
303
+ card.setAttribute('aria-expanded', 'true');
304
+ closeOpenMenu = close;
305
+ document.addEventListener('pointerdown', outside, true);
306
+ document.addEventListener('keydown', escape, true);
307
+ // On a card taller than the screen, under the toolbar (not above the screen) where the card goes on.
308
+ const bar = document.querySelector('.editor-tools')?.getBoundingClientRect().bottom ?? 0;
309
+ const box = dom.getBoundingClientRect();
310
+ const at = menu.getBoundingClientRect();
311
+ if (at.top < bar + 8) menu.style.top = `${Math.min(bar + 8 - box.top, box.height - at.height - 8)}px`;
312
+ if (keyboard)
313
+ (menu.querySelector<HTMLElement>(`[aria-label="${focus}"]`) ?? menu.querySelector('button'))?.focus({ preventScroll: true });
314
+ }
315
+ (dom as CardHost).openMenu = open;
316
+ card.setAttribute('aria-haspopup', 'menu');
317
+ card.setAttribute('aria-expanded', 'false');
318
+ card.addEventListener('mousedown', (e) => e.preventDefault());
319
+ // While a finger is on the card, the text cannot be written in: a phone does not put the cursor in
320
+ // the text next to the card and open the keyboard (and a keyboard that is open closes).
321
+ card.addEventListener('pointerdown', (e) => {
322
+ if (e.pointerType === 'mouse' || view.props.editable?.(view.state) === false) return;
323
+ const before = view.props.editable;
324
+ view.setProps({ editable: () => false });
325
+ let timer = 0;
326
+ const unlock = () => {
327
+ clearTimeout(timer);
328
+ card.removeEventListener('click', later);
329
+ document.removeEventListener('pointercancel', unlock, true);
330
+ view.setProps({ editable: before });
331
+ };
332
+ const later = () => setTimeout(unlock);
333
+ card.addEventListener('click', later, { once: true });
334
+ document.addEventListener('pointercancel', unlock, { capture: true, once: true });
335
+ timer = window.setTimeout(unlock, 1000);
336
+ });
337
+ card.addEventListener('click', (e) => (menu ? close() : open(e.detail === 0)));
338
+ return close;
339
+ }
340
+
341
+ // The address of a picture from its sheet: with its format and crop after it, and marked when it is decorative
342
+ // (crop.mjs).
343
+ const pictureSrc = (props: BlockProps) =>
344
+ joinPicture(
345
+ String(props.src),
346
+ String(props.format ?? ''),
347
+ (props.crop as number[] | null | undefined) ?? null,
348
+ props.decorative === true,
349
+ );
350
+
351
+ // A picture in the text as a card like a block's, cut to its format, with a menu to change it
352
+ // (another picture, its description for screen readers, its format), move it with its paragraph and
353
+ // remove it.
354
+ // sheet: where it is changed (the sheet of the blocks, BlockDialog.tsx).
355
+ function pictureCard(node: Node, view: EditorView, getPos: () => number | undefined, sheet: () => BlockAsk | undefined) {
356
+ const dom = document.createElement('span');
357
+ dom.className = 'site-block site-picture';
358
+ dom.contentEditable = 'false';
359
+ const card = dom.appendChild(document.createElement('button'));
360
+ card.type = 'button';
361
+ card.className = 'site-block-card';
362
+ card.setAttribute('aria-label', t.text.image);
363
+ // Cut to its format as on the site (crop.mjs): in a box of the format.
364
+ const { src, format, crop, decorative } = splitPicture(node.attrs.src);
365
+ if (node.attrs.alt && !decorative) card.setAttribute('aria-description', node.attrs.alt);
366
+ const cut = format ? cropStyle(format, crop) : null;
367
+ const box = cut?.box ? card.appendChild(document.createElement('span')) : card;
368
+ if (cut?.box) {
369
+ box.className = 'picture-crop';
370
+ box.style.cssText = cut.box;
371
+ }
372
+ const img = box.appendChild(document.createElement('img'));
373
+ img.src = shownAt(src);
374
+ img.alt = '';
375
+ if (cut) img.style.cssText = cut.img;
376
+ if (node.attrs.title) img.title = node.attrs.title;
377
+ const describe = async () => {
378
+ const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative });
379
+ const pos = getPos();
380
+ if (!props || pos === undefined) return;
381
+ view.dispatch(
382
+ view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: pictureSrc(props), alt: String(props.alt ?? '').trim() }),
383
+ );
384
+ };
385
+ const remove = () => {
386
+ const pos = getPos();
387
+ if (pos !== undefined) view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
388
+ };
389
+ const close = cardMenu(view, dom, card, t.text.image, () => {
390
+ const pos = getPos();
391
+ return [
392
+ { icon: pencilIcon, label: t.common.edit, action: describe },
393
+ ...moves(view, pos === undefined ? null : view.state.doc.resolve(pos).index(0), sheet()?.template?.locked),
394
+ { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
395
+ ];
396
+ });
397
+ return { dom, update: () => false, stopEvent: () => true, ignoreMutation: () => true, destroy: close };
398
+ }
399
+
400
+ // The link where the cursor is, or in the selection: where it is (all of it, when the cursor is in it)
401
+ // and its address; null when there is none.
402
+ function linkAt(state: EditorView['state']) {
403
+ const type = state.schema.marks.link;
404
+ const { from, to, empty, $from } = state.selection;
405
+ if (!empty) {
406
+ let href = '';
407
+ state.doc.nodesBetween(from, to, (n) => void (href ||= type.isInSet(n.marks)?.attrs.href ?? ''));
408
+ return href ? { from, to, href } : null;
409
+ }
410
+ // The text with the same link around the cursor.
411
+ type Run = { from: number; to: number; mark: Mark };
412
+ const start = $from.start();
413
+ let run: Run | null = null;
414
+ let found = null as Run | null;
415
+ $from.parent.forEach((child, offset) => {
416
+ const mark = type.isInSet(child.marks);
417
+ const a = start + offset;
418
+ const b = a + child.nodeSize;
419
+ if (mark && run && run.mark.eq(mark) && run.to === a) run.to = b;
420
+ else run = mark ? { from: a, to: b, mark } : null;
421
+ if (run && run.from < from && from < run.to) found = { ...run };
422
+ });
423
+ return found && { from: found.from, to: found.to, href: String(found.mark.attrs.href) };
424
+ }
425
+
426
+ // The toolbar's and the selection's "Link": the link sheet (LinkSheet.tsx), then the selection, or
427
+ // the link the cursor is in, gets the address, or loses its link; with nothing selected, the page's
428
+ // name (or the address) is put in as the link.
429
+ async function editLink(view: EditorView) {
430
+ const link = linkAt(view.state);
431
+ const answer = await askLink(link?.href ?? '');
432
+ view.focus();
433
+ if (!answer) return;
434
+ const { state } = view;
435
+ const type = state.schema.marks.link;
436
+ const { from, to } = link ?? state.selection;
437
+ if (from === to) {
438
+ if (answer.href)
439
+ view.dispatch(state.tr.insertText(answer.text, from).addMark(from, from + answer.text.length, type.create({ href: answer.href })));
440
+ return;
441
+ }
442
+ const tr = state.tr.removeMark(from, to, type);
443
+ view.dispatch(answer.href ? tr.addMark(from, to, type.create({ href: answer.href })) : tr);
444
+ }
445
+
446
+ /** What applies where the cursor is, or to all of the selection: 'strong', 'emphasis', 'link',
447
+ * 'h2', 'h3', 'text' (a paragraph, not in a list or quote), 'bullet', 'ordered' and 'quote'; with a colour
448
+ * (text-colors.mjs) 'textColor' and the colour's class ('color-accent'). For the toolbar and the selection's menu,
449
+ * which mark them. */
450
+ export function activeOf(state: EditorView['state']): Set<string> {
451
+ const { selection, storedMarks, schema } = state;
452
+ const { $from, from, to, empty } = selection;
453
+ const on = new Set<string>();
454
+ for (const name of ['strong', 'emphasis', 'link', 'textColor']) {
455
+ const mark = schema.marks[name];
456
+ if (empty ? mark.isInSet(storedMarks ?? $from.marks()) : state.doc.rangeHasMark(from, to, mark)) on.add(name);
457
+ }
458
+ const color = colorAt(state);
459
+ if (color) on.add(colorClass(color));
460
+ const ancestors = Array.from({ length: $from.depth }, (_, i) => $from.node(i + 1).type.name);
461
+ if ($from.parent.type.name === 'heading') on.add(`h${$from.parent.attrs.level}`);
462
+ if (ancestors.includes('bullet_list')) on.add('bullet');
463
+ if (ancestors.includes('ordered_list')) on.add('ordered');
464
+ if (ancestors.includes('blockquote')) on.add('quote');
465
+ if ($from.parent.type.name === 'paragraph' && !on.has('bullet') && !on.has('ordered') && !on.has('quote')) on.add('text');
466
+ return on;
467
+ }
468
+
469
+ // The colour where the cursor is, or the first in the selection (text-colors.mjs); null without one.
470
+ function colorAt(state: EditorView['state']): string | null {
471
+ const type = state.schema.marks.textColor;
472
+ const { from, to, empty, $from } = state.selection;
473
+ if (empty) return type.isInSet(state.storedMarks ?? $from.marks())?.attrs.color ?? null;
474
+ let color: string | null = null;
475
+ state.doc.nodesBetween(from, to, (n) => void (color ??= type.isInSet(n.marks)?.attrs.color ?? null));
476
+ return color;
477
+ }
478
+
479
+ /** Gives the selection one of the site's colours (text-colors.mjs), or takes its colour away (null). */
480
+ export function paint(view: EditorView, color: string | null) {
481
+ const { state } = view;
482
+ const type = state.schema.marks.textColor;
483
+ const { from, to } = state.selection;
484
+ if (from === to) return view.focus();
485
+ const tr = state.tr.removeMark(from, to, type);
486
+ view.dispatch(color ? tr.addMark(from, to, type.create({ color })) : tr);
487
+ view.focus();
488
+ }
489
+
490
+ // off: not there for the selection (the button is hidden).
491
+ type Look = {
492
+ icon: string;
493
+ label: string;
494
+ on: (state: EditorView['state']) => boolean;
495
+ action: (button: HTMLElement) => void;
496
+ off?: (state: EditorView['state']) => boolean;
497
+ };
498
+
499
+ // Whether a heading (H2) is selected, or a part of one.
500
+ function inH2(state: EditorView['state']) {
501
+ let found = false;
502
+ const { from, to } = state.selection;
503
+ state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level === 2));
504
+ return found;
505
+ }
506
+
507
+ // When a word or more is selected: a row of icons over it, to make it bold, italic, a link, one of the site's colours
508
+ // (a menu under the button, opened by `colors`), the page's H1 (`h1`), a heading or a subheading; the ones that apply to
509
+ // the selection are marked. On a phone, where the phone's own menu is over the selection and its handles under it, the
510
+ // row is at the bottom of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
511
+ function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button: HTMLElement) => void) {
512
+ return new Plugin({
513
+ view(view) {
514
+ const menu = document.body.appendChild(document.createElement('div'));
515
+ menu.className = 'editor-selection-menu';
516
+ menu.setAttribute('role', 'toolbar');
517
+ menu.setAttribute('aria-label', t.text.selection);
518
+ menu.hidden = true;
519
+ const run = <T,>(command: { key: CmdKey<T> }, payload?: T) => void callCommand(command.key, payload)(ctx);
520
+ const marked = (state: EditorView['state'], name: string) => activeOf(state).has(name);
521
+ const heading = (state: EditorView['state'], level: number) => activeOf(state).has(`h${level}`);
522
+ const looks: Look[] = [
523
+ { icon: boldIcon, label: t.text.bold, on: (s) => marked(s, 'strong'), action: () => run(toggleStrongCommand) },
524
+ { icon: italicIcon, label: t.text.italic, on: (s) => marked(s, 'emphasis'), action: () => run(toggleEmphasisCommand) },
525
+ {
526
+ icon: linkIcon,
527
+ label: t.text.link,
528
+ on: (s) => marked(s, 'link'),
529
+ action: () => void editLink(view).then(() => update(view)),
530
+ },
531
+ {
532
+ icon: paletteIcon,
533
+ label: t.text.color,
534
+ on: (s) => marked(s, 'textColor'),
535
+ action: colors,
536
+ off: sections ? inH2 : undefined,
537
+ },
538
+ ...(h1 ? [1, 2, 3] : [2, 3]).map((level) => ({
539
+ icon: [h1Icon, h2Icon, h3Icon][level - 1],
540
+ label: [t.text.pageHeading, t.text.heading, t.text.subheading][level - 1],
541
+ on: (s: EditorView['state']) => heading(s, level),
542
+ action: () => (heading(view.state, level) ? run(turnIntoTextCommand) : run(wrapInHeadingCommand, level)),
543
+ })),
544
+ ];
545
+ const buttons = looks.map((look) => {
546
+ const b = menu.appendChild(document.createElement('button'));
547
+ b.type = 'button';
548
+ b.title = look.label;
549
+ b.setAttribute('aria-label', look.label);
550
+ b.innerHTML = look.icon.replace('<svg', '<svg width="20" height="20" fill="currentColor" aria-hidden="true"');
551
+ if (look.action === colors) b.setAttribute('aria-haspopup', 'menu');
552
+ const act = () => {
553
+ look.action(b);
554
+ update(view);
555
+ // Again when "Gem ændringer" has come after the first change.
556
+ setTimeout(follow, 100);
557
+ };
558
+ // The selection stays: no focus taken with the mouse, and a tap does not end the selection
559
+ // (its click is the phone's, so the tap acts itself).
560
+ b.addEventListener('mousedown', (e) => e.preventDefault());
561
+ b.addEventListener('click', act);
562
+ b.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
563
+ b.addEventListener('touchend', (e) => {
564
+ e.preventDefault();
565
+ act();
566
+ });
567
+ return b;
568
+ });
569
+ function update(view: EditorView) {
570
+ const { selection, doc } = view.state;
571
+ const shown =
572
+ view.hasFocus() &&
573
+ selection instanceof TextSelection &&
574
+ !selection.empty &&
575
+ doc.textBetween(selection.from, selection.to).trim() !== '';
576
+ menu.hidden = !shown;
577
+ if (!shown) return;
578
+ looks.forEach((look, i) => {
579
+ buttons[i].hidden = !!look.off?.(view.state);
580
+ buttons[i].setAttribute('aria-pressed', String(look.on(view.state)));
581
+ });
582
+ const { offsetWidth: width, offsetHeight: height } = menu;
583
+ const screen = visualViewport ?? { offsetLeft: 0, offsetTop: 0, width: innerWidth, height: innerHeight };
584
+ if (matchMedia('(pointer: coarse)').matches) {
585
+ menu.style.left = `${screen.offsetLeft + (screen.width - width) / 2}px`;
586
+ // Over "Gem ændringer" when it is there (FormFrame.tsx).
587
+ const save = document.querySelector('.editor-save-bar')?.getBoundingClientRect().top ?? Infinity;
588
+ menu.style.top = `${Math.min(screen.offsetTop + screen.height, save) - height - 12}px`;
589
+ return;
590
+ }
591
+ const text = view.dom.getBoundingClientRect();
592
+ const start = view.coordsAtPos(selection.from);
593
+ const end = view.coordsAtPos(selection.to);
594
+ const middle = start.top === end.top ? (start.left + end.right) / 2 : text.left + text.width / 2;
595
+ // Over the selection; under it when the toolbar is there.
596
+ const bar = document.querySelector('.editor-tools')?.getBoundingClientRect().bottom ?? 0;
597
+ const over = start.top - height - 10;
598
+ menu.style.left = `${Math.max(4, Math.min(middle - width / 2, innerWidth - width - 4))}px`;
599
+ menu.style.top = `${over > bar ? over : end.bottom + 10}px`;
600
+ }
601
+ // Gone when the text is left (not for a tap on the menu itself, which keeps the focus).
602
+ const refresh = () => setTimeout(() => update(view));
603
+ // Where it is follows the page and the keyboard.
604
+ const follow = () => menu.hidden || update(view);
605
+ view.dom.addEventListener('blur', refresh);
606
+ view.dom.addEventListener('focus', refresh);
607
+ addEventListener('scroll', follow, { passive: true });
608
+ visualViewport?.addEventListener('resize', follow);
609
+ visualViewport?.addEventListener('scroll', follow);
610
+ return {
611
+ update,
612
+ destroy: () => {
613
+ view.dom.removeEventListener('blur', refresh);
614
+ view.dom.removeEventListener('focus', refresh);
615
+ removeEventListener('scroll', follow);
616
+ visualViewport?.removeEventListener('resize', follow);
617
+ visualViewport?.removeEventListener('scroll', follow);
618
+ menu.remove();
619
+ },
620
+ };
621
+ },
622
+ });
623
+ }
624
+
625
+ // A block in the text as a card: the block as it looks on the site (its draw, src/apps/<app>/blocks/), in a thin frame;
626
+ // for screen readers its name and what is in it (describe in blocks.ts). Tapping it opens a row of
627
+ // icons on it to edit, hide or show, move and remove it. cards: how to draw the previews of the
628
+ // text again, when what they show from elsewhere has changed.
629
+ function blockCard(
630
+ node: Node,
631
+ view: EditorView,
632
+ getPos: () => number | undefined,
633
+ asker: () => BlockAsk | undefined,
634
+ cards: Set<() => void>,
635
+ ) {
636
+ const ask = asker();
637
+ const block = node.attrs.type === HTML ? htmlBlock : ask?.list.find((b) => b.type === node.attrs.type);
638
+ const hidden = node.attrs.props.hidden === true;
639
+ // A block gone from the site by itself (a notice whose time is over) is greyed like a hidden one.
640
+ const over = !!block?.over?.(node.attrs.props);
641
+ const dom = document.createElement('div');
642
+ dom.className = hidden || over ? 'site-block site-block-hidden' : 'site-block';
643
+ dom.contentEditable = 'false';
644
+ const card = dom.appendChild(document.createElement('button'));
645
+ card.type = 'button';
646
+ card.className = 'site-block-card';
647
+ const name = block?.label ?? node.attrs.type;
648
+ card.setAttribute('aria-label', name);
649
+ if (hidden) {
650
+ // An eye struck through in the corner (for screen readers it says so in the description).
651
+ const badge = card.appendChild(document.createElement('span'));
652
+ badge.className = 'site-block-hidden-badge';
653
+ badge.innerHTML = eyeSlashIcon.replace('<svg', '<svg width="18" height="18" fill="currentColor" aria-hidden="true"');
654
+ }
655
+ const shown = card.appendChild(document.createElement('div'));
656
+ shown.className = ask?.data.page ? 'site-block-preview site-block-section not-prose' : 'site-block-preview not-prose';
657
+ shown.setAttribute('aria-hidden', 'true');
658
+ const root = createRoot(shown);
659
+ const draw = () => {
660
+ const ask = asker();
661
+ if (!ask) return;
662
+ const lines = block ? describe(block, node.attrs.props, ask.data) : [];
663
+ card.setAttribute('aria-description', [hidden ? t.text.hiddenOnPage : '', ...lines].filter(Boolean).join('. '));
664
+ // Without a picture of it, what is in it, in lines.
665
+ const picture = preview(node.attrs.type, node.attrs.props, ask.data) ?? (
666
+ <div className="site-block-lines">
667
+ {lines.map((line, i) => (
668
+ <span key={i}>{line}</span>
669
+ ))}
670
+ </div>
671
+ );
672
+ flushSync(() => root.render(picture));
673
+ };
674
+ draw();
675
+ cards.add(draw);
676
+
677
+ const at = () => {
678
+ const pos = getPos();
679
+ return pos === undefined ? null : { pos, $pos: view.state.doc.resolve(pos) };
680
+ };
681
+ // Where the card is at the top of the text, to move it.
682
+ const top = () => {
683
+ const pos = getPos();
684
+ return pos === undefined ? null : view.state.doc.resolve(pos).index(0);
685
+ };
686
+ const edit = async () => {
687
+ const props = await asker()?.edit(node.attrs.type, node.attrs.props);
688
+ const here = at();
689
+ // The block's fields as changed; still hidden if it was.
690
+ if (props && here)
691
+ view.dispatch(
692
+ view.state.tr.setNodeMarkup(here.pos, undefined, { ...node.attrs, props: hidden ? { ...props, hidden: true } : props }),
693
+ );
694
+ };
695
+ const toggle = () => {
696
+ const here = at();
697
+ if (!here) return;
698
+ const { hidden: _, ...props } = node.attrs.props;
699
+ view.dispatch(view.state.tr.setNodeMarkup(here.pos, undefined, { ...node.attrs, props: hidden ? props : { ...props, hidden: true } }));
700
+ };
701
+ const remove = () => {
702
+ const here = at();
703
+ if (here) view.dispatch(view.state.tr.delete(here.pos, here.pos + node.nodeSize));
704
+ };
705
+
706
+ // The choices that can be made here: no "Ret" without fields (but to where it is edited instead,
707
+ // Block.edit), no move past the start or end. By the template (collections.ts): a locked block is
708
+ // only edited, and the last shown one of a block the page must have cannot be hidden or removed.
709
+ const close = cardMenu(view, dom, card, name, () => {
710
+ const i = top();
711
+ const rules = asker()?.template;
712
+ const type = node.attrs.type;
713
+ const locked = !!rules?.locked?.includes(type);
714
+ const shownOfType = blocksOf(view.state.doc).filter((b) => b.type === type && b.hidden === false).length;
715
+ // (Not written with "!" before "hidden", which Tailwind would take for a class for the site.)
716
+ const last = hidden === false && !!rules?.required?.includes(type) && shownOfType <= 1;
717
+ const stays = locked || last;
718
+ return [
719
+ block?.fields.length
720
+ ? { icon: pencilIcon, label: t.common.edit, action: edit }
721
+ : block?.edit && { icon: pencilIcon, label: block.edit.text, action: () => leaveTo(`#${block.edit!.to}`) },
722
+ block?.shown || (stays && hidden === false)
723
+ ? undefined
724
+ : { icon: hidden ? eyeIcon : eyeSlashIcon, label: hidden ? t.text.showOnPage : t.text.hideOnPage, action: toggle },
725
+ ...(locked ? [] : moves(view, i, rules?.locked)),
726
+ stays ? undefined : { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
727
+ ];
728
+ });
729
+ // A changed block is drawn anew (update false); taps on the card and its menu are not ProseMirror's.
730
+ return {
731
+ dom,
732
+ update: () => false,
733
+ stopEvent: () => true,
734
+ ignoreMutation: () => true,
735
+ destroy: () => {
736
+ close();
737
+ cards.delete(draw);
738
+ // Not while React may be drawing (the editor goes with its form).
739
+ queueMicrotask(() => root.unmount());
740
+ },
741
+ };
742
+ }
743
+
744
+ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
745
+ { value, onChange, addImage, blocks, sheet: sheetProp, short, h1, looks, sections },
746
+ ref,
747
+ ) {
748
+ const spaced = !!blocks;
749
+ const root = useRef<HTMLDivElement>(null);
750
+ const toolbar = useRef<HTMLDivElement>(null);
751
+ const editor = useRef<Editor | null>(null);
752
+ const changed = useRef(onChange);
753
+ changed.current = onChange;
754
+ const sheeted = useRef(blocks ?? sheetProp);
755
+ sheeted.current = blocks ?? sheetProp;
756
+ const looked = useRef(looks);
757
+ looked.current = looks;
758
+ // What applies where the cursor is (activeOf), marked in the toolbar.
759
+ const [active, setActive] = useState<string[]>([]);
760
+ // The colour button over the selection, while its menu with the colours is open.
761
+ const [colorsAt, setColorsAt] = useState<HTMLElement | null>(null);
762
+ useEffect(() => {
763
+ colorsAt?.setAttribute('aria-expanded', 'true');
764
+ return () => colorsAt?.setAttribute('aria-expanded', 'false');
765
+ }, [colorsAt]);
766
+ // The previews of the blocks, drawn again when what they show from elsewhere changes: the lists,
767
+ // the testimonials, the rest of the site, the page's address or a fixed page's other fields.
768
+ const cards = useRef(new Set<() => void>());
769
+ const data = blocks?.data;
770
+ useEffect(
771
+ () => queueMicrotask(() => cards.current.forEach((draw) => draw())),
772
+ [data?.lists, data?.testimonials, data?.site, data?.path, data?.page?.values],
773
+ );
774
+
775
+ useEffect(() => {
776
+ let cancelled = false;
777
+ // Tell the form as soon as the text changes (Milkdown's own listener waits 200 ms); not for the
778
+ // free lines put in as the text is loaded.
779
+ let loaded = false;
780
+ const marking = $prose(
781
+ () =>
782
+ new Plugin({
783
+ view: () => ({
784
+ update: (view) => {
785
+ const now = [...activeOf(view.state)].sort();
786
+ setActive((was) => (was.join() === now.join() ? was : now));
787
+ },
788
+ }),
789
+ }),
790
+ );
791
+ const onEdit = $prose(
792
+ () => new Plugin({ view: () => ({ update: (view, prev) => void (!loaded || view.state.doc.eq(prev.doc) || changed.current()) }) }),
793
+ );
794
+ const selecting = $prose((ctx) => selectionMenu(ctx, !!h1, !!sections, (button) => setColorsAt((open) => (open ? null : button))));
795
+ const pictureView = $view(imageSchema.node, () => (node, view, getPos) => pictureCard(node, view, getPos, () => sheeted.current));
796
+ const cardView = $view(
797
+ siteBlockSchema.node,
798
+ () => (node, view, getPos) => blockCard(node, view, getPos, () => sheeted.current, cards.current),
799
+ );
800
+ // The look of each part on the site, as data-look on it.
801
+ const looking = $prose(
802
+ () =>
803
+ new Plugin({
804
+ props: {
805
+ decorations: (state) => {
806
+ const looks = looked.current;
807
+ if (!looks) return null;
808
+ const nodes: { node: Node; pos: number }[] = [];
809
+ state.doc.forEach((node, pos) => void nodes.push({ node, pos }));
810
+ const marks = looks(nodes.map(({ node }) => lookItem(node)));
811
+ return DecorationSet.create(
812
+ state.doc,
813
+ nodes.flatMap(({ node, pos }, i) =>
814
+ marks[i] ? [Decoration.node(pos, pos + node.nodeSize, { 'data-look': marks[i]! })] : [],
815
+ ),
816
+ );
817
+ },
818
+ },
819
+ }),
820
+ );
821
+ const made = makeEditor(root.current, value)
822
+ .use(cardView)
823
+ .use(pictureView)
824
+ .use(selecting)
825
+ .use(cardDeletes)
826
+ .use(marking)
827
+ .use(onEdit)
828
+ .use(looking)
829
+ .use(gapHere)
830
+ .use(history)
831
+ .use(clipboard);
832
+ (spaced ? made.use(spacing) : made).create().then((created) => {
833
+ if (cancelled) return created.destroy();
834
+ editor.current = created;
835
+ // The free lines between the blocks of the text as it is loaded, and the cursor in the first,
836
+ // so a key pressed before the text is tapped does not replace the first block.
837
+ if (spaced)
838
+ created.action((ctx) => {
839
+ const view = ctx.get(editorViewCtx);
840
+ view.dispatch(view.state.tr.setMeta('addToHistory', false));
841
+ view.dispatch(view.state.tr.setSelection(Selection.atStart(view.state.doc)).setMeta('addToHistory', false));
842
+ });
843
+ loaded = true;
844
+ });
845
+ return () => {
846
+ cancelled = true;
847
+ editor.current?.destroy();
848
+ editor.current = null;
849
+ };
850
+ // The editor is made once per file; the parent gives each file its own key.
851
+ }, []);
852
+
853
+ useImperativeHandle(ref, () => ({
854
+ markdown: () => editor.current?.action(markdownOf) ?? null,
855
+ parts: () => editor.current?.action(partsOf) ?? null,
856
+ }));
857
+
858
+ function run<T>(command: { key: CmdKey<T> }, payload?: T) {
859
+ editor.current?.action(callCommand(command.key, payload));
860
+ }
861
+
862
+ // Makes the text where the cursor is a heading ('h1', 'h2', 'h3'), a paragraph ('text'), a list
863
+ // ('bullet', 'ordered') or a quote ('quote'), whatever it is now: a list becomes the other kind of
864
+ // list, and the text of a list or a quote comes out of it to be something else.
865
+ function setKind(kind: string) {
866
+ editor.current?.action((ctx) => {
867
+ const view = ctx.get(editorViewCtx);
868
+ const { schema, selection } = view.state;
869
+ const is = (k: string) => activeOf(view.state).has(k);
870
+ const outOfLists = () => {
871
+ for (let i = 0; i < 10 && (is('bullet') || is('ordered')) && liftListItem(schema.nodes.list_item)(view.state, view.dispatch); i++);
872
+ };
873
+ const outOfQuotes = () => {
874
+ for (let i = 0; i < 10 && is('quote') && lift(view.state, view.dispatch); i++);
875
+ };
876
+ if (kind === 'quote') {
877
+ if (!is('quote')) {
878
+ outOfLists();
879
+ if (is('h1') || is('h2') || is('h3')) callCommand(turnIntoTextCommand.key)(ctx);
880
+ callCommand(wrapInBlockquoteCommand.key)(ctx);
881
+ }
882
+ return view.focus();
883
+ }
884
+ if (kind === 'bullet' || kind === 'ordered') {
885
+ const list = schema.nodes[kind === 'bullet' ? 'bullet_list' : 'ordered_list'];
886
+ const { $from } = selection;
887
+ for (let d = $from.depth; d > 0; d--) {
888
+ const node = $from.node(d);
889
+ if (node.type.name !== 'bullet_list' && node.type.name !== 'ordered_list') continue;
890
+ if (node.type !== list) view.dispatch(view.state.tr.setNodeMarkup($from.before(d), list, { spread: node.attrs.spread }));
891
+ return view.focus();
892
+ }
893
+ outOfQuotes();
894
+ if (is('h1') || is('h2') || is('h3')) callCommand(turnIntoTextCommand.key)(ctx);
895
+ callCommand((kind === 'bullet' ? wrapInBulletListCommand : wrapInOrderedListCommand).key)(ctx);
896
+ return view.focus();
897
+ }
898
+ outOfLists();
899
+ outOfQuotes();
900
+ if (kind === 'text') callCommand(turnIntoTextCommand.key)(ctx);
901
+ else callCommand(wrapInHeadingCommand.key, Number(kind.slice(1)))(ctx);
902
+ view.focus();
903
+ });
904
+ }
905
+
906
+ function link() {
907
+ editor.current?.action((ctx) => void editLink(ctx.get(editorViewCtx)));
908
+ }
909
+
910
+ function color(name: string | null) {
911
+ editor.current?.action((ctx) => paint(ctx.get(editorViewCtx), name));
912
+ }
913
+
914
+ // A new picture from the gallery, then the sheet with it and what it shows, where it can still be
915
+ // changed or left out (the browser's own box for what it shows without a sheet).
916
+ async function image() {
917
+ const src = await addImage!();
918
+ if (!src) return;
919
+ const sheet = blocks ?? sheetProp;
920
+ const picked = await sheet?.edit(IMAGE, { src, alt: '' }, true);
921
+ if (picked) run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
922
+ }
923
+
924
+ // Puts the chosen block where the cursor is (a paragraph is split around it), or after the block
925
+ // that is selected. With no text after it, an empty paragraph follows, where the cursor goes, so
926
+ // the next text or block comes after it instead of replacing it.
927
+ async function block() {
928
+ // The blocks and pictures in the text now, for how many of a type there may be (the template).
929
+ let have: string[] = [];
930
+ editor.current?.action((ctx) => {
931
+ const doc = ctx.get(editorViewCtx).state.doc;
932
+ have = blocksOf(doc).map((b) => b.type);
933
+ doc.descendants((n) => void (n.type.name === 'image' && have.push(IMAGE)));
934
+ });
935
+ const chosen = await blocks!.choose({ image: !!addImage, have });
936
+ if (!chosen) return;
937
+ if (chosen.type === IMAGE) return image();
938
+ editor.current?.action((ctx) => {
939
+ const view = ctx.get(editorViewCtx);
940
+ const { state } = view;
941
+ const node = siteBlockSchema.type(ctx).create({ type: chosen.type, props: chosen.props });
942
+ let tr = state.tr;
943
+ // Where the new block ends, when the cursor must be moved past it (-1: it is in text already).
944
+ let end = -1;
945
+ if (state.selection instanceof NodeSelection) {
946
+ tr = tr.insert(state.selection.to, node);
947
+ end = state.selection.to + node.nodeSize;
948
+ } else {
949
+ tr = tr.replaceSelectionWith(node);
950
+ if (tr.selection instanceof NodeSelection) end = tr.selection.to;
951
+ }
952
+ if (end >= 0) {
953
+ if (!tr.doc.resolve(end).nodeAfter?.isTextblock) tr = tr.insert(end, state.schema.nodes.paragraph.create());
954
+ tr = tr.setSelection(TextSelection.create(tr.doc, end + 1));
955
+ }
956
+ // A section whose heading is written above its card gets one, its name, selected to write over.
957
+ const chosenBlock = blocks!.list.find((b) => b.type === chosen.type);
958
+ if (chosenBlock?.lead?.heading) {
959
+ let at = -1;
960
+ tr.doc.forEach((n, offset) => void (n === node && (at = offset)));
961
+ if (at >= 0) {
962
+ tr = tr.insert(at, state.schema.nodes.heading.create({ level: 2 }, state.schema.text(chosenBlock.label)));
963
+ tr = tr.setSelection(TextSelection.create(tr.doc, at + 1, at + 1 + chosenBlock.label.length));
964
+ }
965
+ }
966
+ view.dispatch(tr.scrollIntoView());
967
+ view.focus();
968
+ });
969
+ }
970
+
971
+ const tools: { label: string; icon: typeof TextB; action: () => void; on?: string }[] = [
972
+ { label: t.text.bold, icon: TextB, action: () => run(toggleStrongCommand), on: 'strong' },
973
+ { label: t.text.italic, icon: TextItalic, action: () => run(toggleEmphasisCommand), on: 'emphasis' },
974
+ { label: t.text.link, icon: LinkSimple, action: link, on: 'link' },
975
+ ...(h1 ? [{ label: t.text.pageHeading, icon: TextHOne, action: () => setKind('h1'), on: 'h1' }] : []),
976
+ { label: t.text.heading, icon: TextHTwo, action: () => setKind('h2'), on: 'h2' },
977
+ { label: t.text.subheading, icon: TextHThree, action: () => setKind('h3'), on: 'h3' },
978
+ { label: t.text.paragraph, icon: TextT, action: () => setKind('text'), on: 'text' },
979
+ { label: t.text.bulletList, icon: ListBullets, action: () => setKind('bullet'), on: 'bullet' },
980
+ { label: t.text.orderedList, icon: ListNumbers, action: () => setKind('ordered'), on: 'ordered' },
981
+ { label: t.text.quote, icon: Quotes, action: () => setKind('quote'), on: 'quote' },
982
+ // A picture and the blocks are put in with "+" (a sheet with them all); without blocks (a blog
983
+ // post) a picture has its own button.
984
+ ...(blocks
985
+ ? [{ label: t.text.insert, icon: Plus, action: block }]
986
+ : addImage
987
+ ? [{ label: t.text.image, icon: Image, action: image }]
988
+ : []),
989
+ { label: t.text.undo, icon: ArrowUUpLeft, action: () => run(undoCommand) },
990
+ { label: t.text.redo, icon: ArrowUUpRight, action: () => run(redoCommand) },
991
+ ];
992
+
993
+ // When the toolbar is too short for all its buttons, the kinds of text (H1, H2, H3, T, the bulleted and
994
+ // the numbered list and the quote) are one button: the one where the cursor is, with an arrow, and
995
+ // the others under it.
996
+ const kinds = tools.filter((t) => ['h1', 'h2', 'h3', 'text', 'bullet', 'ordered', 'quote'].includes(t.on ?? ''));
997
+ const kind = kinds.find((t) => active.includes(t.on!));
998
+ const shown = kind ?? kinds.find((t) => t.on === 'text')!;
999
+ const [narrow, setNarrow] = useState(false);
1000
+ // The button that opened the menu with the kinds of text, while it is open.
1001
+ const [kindsAt, setKindsAt] = useState<HTMLElement | null>(null);
1002
+ useEffect(() => {
1003
+ const bar = toolbar.current!;
1004
+ // A button is 2.5rem, with 0.125rem between them and 0.375rem around them, and each of the lines
1005
+ // between the groups 0.5rem (the classes below).
1006
+ const fits = () => setNarrow(bar.clientWidth < tools.length * 42 + 10 + 3 * 10);
1007
+ const watch = new ResizeObserver(fits);
1008
+ watch.observe(bar);
1009
+ return () => watch.disconnect();
1010
+ }, [tools.length]);
1011
+ const tool = ({ label, icon, action, on }: (typeof tools)[number]) => (
1012
+ <ToolbarButton key={label} icon={icon} label={label} selected={on ? active.includes(on) : undefined} onClick={action} />
1013
+ );
1014
+
1015
+ // The lines between the marks on the text, the kinds of text, "+" (or "Billede") and "Fortryd".
1016
+ const lineBefore = (item: (typeof tools)[number]) =>
1017
+ item === kinds[0] || item.icon === Plus || item.icon === Image || item.icon === ArrowUUpLeft;
1018
+ // A button of the toolbar; when it is too short, the kinds of text are one button (at the first), with a menu.
1019
+ const button = (item: (typeof tools)[number]) =>
1020
+ !narrow || !kinds.includes(item) ? (
1021
+ tool(item)
1022
+ ) : item === kinds[0] ? (
1023
+ // Not marked: it always shows the kind there is (the menu under it marks it).
1024
+ <ToolbarButton
1025
+ key="kinds"
1026
+ icon={shown.icon}
1027
+ label={t.text.kindIs(shown.label)}
1028
+ menu={!!kindsAt}
1029
+ onClick={(e) => setKindsAt(kindsAt ? null : e.currentTarget)}
1030
+ />
1031
+ ) : null;
1032
+
1033
+ return (
1034
+ <div className="rounded-panel border border-line bg-surface">
1035
+ {/* Stays at the top while scrolling through the text, a little under the app's top bar (Screen.tsx,
1036
+ 4rem and its line); on a phone one row that scrolls sideways. The buttons are centred, but
1037
+ start at the left edge when they do not fit (safe), so none is out of reach. */}
1038
+ <Toolbar
1039
+ ref={toolbar}
1040
+ label={t.text.tools}
1041
+ className="editor-tools sticky top-[calc(4.5rem+1px+env(safe-area-inset-top))] z-10 justify-center-safe overflow-x-auto rounded-t-panel border-b border-line md:flex-wrap"
1042
+ >
1043
+ {tools.map((tool) => (
1044
+ <ReactFragment key={tool.label}>
1045
+ {lineBefore(tool) && <span aria-hidden="true" className="mx-1 my-2 w-px shrink-0 self-stretch bg-line" />}
1046
+ {button(tool)}
1047
+ </ReactFragment>
1048
+ ))}
1049
+ </Toolbar>
1050
+ {/* The site's colours (text-colors.mjs), each named in its colour, as it is in the text (its class), and "Ingen
1051
+ farve" in the text's own. */}
1052
+ <Menu anchor={colorsAt} label={t.text.color} onClose={() => setColorsAt(null)}>
1053
+ {TEXT_COLORS.map((name) => (
1054
+ <MenuItem key={name} icon={TextAa} selected={active.includes(colorClass(name))} onClick={() => color(name)}>
1055
+ <span className={colorClass(name)}>{t.text.colors[name as keyof typeof t.text.colors]}</span>
1056
+ </MenuItem>
1057
+ ))}
1058
+ <MenuItem icon={TextAa} selected={!active.includes('textColor')} onClick={() => color(null)}>
1059
+ <span className="text-ink">{t.text.noColor}</span>
1060
+ </MenuItem>
1061
+ </Menu>
1062
+ <Menu anchor={kindsAt} label={t.text.kind} onClose={() => setKindsAt(null)}>
1063
+ {kinds.map(({ label, icon, action, on }) => (
1064
+ <MenuItem key={label} icon={icon} selected={active.includes(on!)} onClick={action}>
1065
+ {label}
1066
+ </MenuItem>
1067
+ ))}
1068
+ </Menu>
1069
+ <div
1070
+ ref={root}
1071
+ className={`editor-text prose max-w-none px-5 py-4 md:px-8 ${short ? 'editor-text-short' : ''} ${spaced ? 'editor-spaced' : ''}`}
1072
+ />
1073
+ </div>
1074
+ );
1075
+ });