@morphemecms/cms 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/astro.mjs +3 -0
  3. package/bin/morpheme.mjs +3 -1
  4. package/defaults/blockTexts.ts +2 -2
  5. package/defaults/collections.ts +10 -0
  6. package/defaults/editor.ts +57 -53
  7. package/defaults/privacy.ts +4 -1
  8. package/defaults/rules.ts +36 -38
  9. package/defaults/standardPages.ts +0 -3
  10. package/defaults/texts.ts +11 -2
  11. package/demo/src/content/pages/about.md +2 -0
  12. package/icons/lucide/browser-fill.svg +1 -0
  13. package/icons/lucide/buildings-fill.svg +1 -0
  14. package/icons/lucide/certificate-fill.svg +1 -0
  15. package/icons/lucide/chart-line-up-fill.svg +1 -0
  16. package/icons/lucide/chats-fill.svg +1 -0
  17. package/icons/lucide/envelope-simple-fill.svg +1 -0
  18. package/icons/lucide/file-text-fill.svg +1 -0
  19. package/icons/lucide/folder-simple-fill.svg +1 -0
  20. package/icons/lucide/gauge.svg +1 -1
  21. package/icons/lucide/gear-six.svg +1 -0
  22. package/icons/lucide/globe-simple.svg +1 -0
  23. package/icons/lucide/heart-fill.svg +1 -0
  24. package/icons/lucide/layout.svg +1 -0
  25. package/icons/lucide/list-checks-fill.svg +1 -0
  26. package/icons/lucide/magnifying-glass-fill.svg +1 -0
  27. package/icons/lucide/megaphone-fill.svg +1 -0
  28. package/icons/lucide/newspaper-fill.svg +1 -0
  29. package/icons/lucide/paint-brush-broad-fill.svg +1 -0
  30. package/icons/lucide/puzzle-piece-fill.svg +1 -0
  31. package/icons/lucide/shield-check-fill.svg +1 -0
  32. package/icons/lucide/sidebar-simple.svg +1 -1
  33. package/icons/lucide/sidebar.svg +1 -0
  34. package/icons/lucide/square-half-bottom-fill.svg +1 -0
  35. package/icons/lucide/square-half.svg +1 -0
  36. package/icons/lucide/squares-four-fill.svg +1 -0
  37. package/icons/lucide/storefront-fill.svg +1 -0
  38. package/icons/lucide/tag-fill.svg +1 -0
  39. package/icons/lucide/toggle-right-fill.svg +1 -0
  40. package/icons/lucide/trash-fill.svg +1 -0
  41. package/icons/lucide/user-fill.svg +1 -0
  42. package/icons/lucide/wrench-fill.svg +1 -0
  43. package/icons/material-symbols/browser-fill.svg +1 -0
  44. package/icons/material-symbols/buildings-fill.svg +1 -0
  45. package/icons/material-symbols/certificate-fill.svg +1 -0
  46. package/icons/material-symbols/chart-line-up-fill.svg +1 -0
  47. package/icons/material-symbols/chats-fill.svg +1 -0
  48. package/icons/material-symbols/envelope-simple-fill.svg +1 -0
  49. package/icons/material-symbols/file-text-fill.svg +1 -0
  50. package/icons/material-symbols/folder-simple-fill.svg +1 -0
  51. package/icons/material-symbols/gauge.svg +1 -1
  52. package/icons/material-symbols/gear-six.svg +1 -0
  53. package/icons/material-symbols/globe-simple.svg +1 -0
  54. package/icons/material-symbols/heart-fill.svg +1 -0
  55. package/icons/material-symbols/layout.svg +1 -0
  56. package/icons/material-symbols/list-checks-fill.svg +1 -0
  57. package/icons/material-symbols/magnifying-glass-fill.svg +1 -0
  58. package/icons/material-symbols/megaphone-fill.svg +1 -0
  59. package/icons/material-symbols/newspaper-fill.svg +1 -0
  60. package/icons/material-symbols/paint-brush-broad-fill.svg +1 -0
  61. package/icons/material-symbols/puzzle-piece-fill.svg +1 -0
  62. package/icons/material-symbols/shield-check-fill.svg +1 -0
  63. package/icons/material-symbols/sidebar-simple.svg +1 -1
  64. package/icons/material-symbols/sidebar.svg +1 -0
  65. package/icons/material-symbols/square-half-bottom-fill.svg +1 -0
  66. package/icons/material-symbols/square-half.svg +1 -0
  67. package/icons/material-symbols/squares-four-fill.svg +1 -0
  68. package/icons/material-symbols/storefront-fill.svg +1 -0
  69. package/icons/material-symbols/tag-fill.svg +1 -0
  70. package/icons/material-symbols/toggle-right-fill.svg +1 -0
  71. package/icons/material-symbols/trash-fill.svg +1 -0
  72. package/icons/material-symbols/user-fill.svg +1 -0
  73. package/icons/material-symbols/wrench-fill.svg +1 -0
  74. package/icons/phosphor/browser-fill.svg +1 -0
  75. package/icons/phosphor/buildings-fill.svg +1 -0
  76. package/icons/phosphor/certificate-fill.svg +1 -0
  77. package/icons/phosphor/chart-line-up-fill.svg +1 -0
  78. package/icons/phosphor/chats-fill.svg +1 -0
  79. package/icons/phosphor/envelope-simple-fill.svg +1 -0
  80. package/icons/phosphor/file-text-fill.svg +1 -0
  81. package/icons/phosphor/folder-simple-fill.svg +1 -0
  82. package/icons/phosphor/gear-six.svg +1 -0
  83. package/icons/phosphor/globe-simple.svg +1 -0
  84. package/icons/phosphor/heart-fill.svg +1 -0
  85. package/icons/phosphor/layout.svg +1 -0
  86. package/icons/phosphor/list-checks-fill.svg +1 -0
  87. package/icons/phosphor/magnifying-glass-fill.svg +1 -0
  88. package/icons/phosphor/megaphone-fill.svg +1 -0
  89. package/icons/phosphor/newspaper-fill.svg +1 -0
  90. package/icons/phosphor/paint-brush-broad-fill.svg +1 -0
  91. package/icons/phosphor/puzzle-piece-fill.svg +1 -0
  92. package/icons/phosphor/shield-check-fill.svg +1 -0
  93. package/icons/phosphor/sidebar.svg +1 -0
  94. package/icons/phosphor/square-half-bottom-fill.svg +1 -0
  95. package/icons/phosphor/square-half.svg +1 -0
  96. package/icons/phosphor/squares-four-fill.svg +1 -0
  97. package/icons/phosphor/storefront-fill.svg +1 -0
  98. package/icons/phosphor/tag-fill.svg +1 -0
  99. package/icons/phosphor/toggle-right-fill.svg +1 -0
  100. package/icons/phosphor/trash-fill.svg +1 -0
  101. package/icons/phosphor/user-fill.svg +1 -0
  102. package/icons/phosphor/wrench-fill.svg +1 -0
  103. package/lib/breakpoints.d.mts +13 -0
  104. package/lib/breakpoints.mjs +20 -0
  105. package/lib/breakpoints.test.mjs +39 -0
  106. package/lib/build-lane.d.mts +15 -0
  107. package/lib/build-lane.mjs +23 -0
  108. package/lib/build-lane.test.mjs +74 -0
  109. package/lib/build-settings.d.mts +16 -0
  110. package/lib/build-settings.mjs +39 -0
  111. package/lib/build-settings.test.mjs +41 -0
  112. package/lib/contracts.d.mts +3 -2
  113. package/lib/contracts.mjs +3 -2
  114. package/lib/drawer-swipe.d.mts +10 -0
  115. package/lib/drawer-swipe.mjs +17 -0
  116. package/lib/drawer-swipe.test.mjs +33 -0
  117. package/lib/drawerKeys.d.mts +9 -0
  118. package/lib/drawerKeys.mjs +26 -0
  119. package/lib/drawerKeys.test.mjs +21 -0
  120. package/lib/eyebrow.d.mts +6 -0
  121. package/lib/eyebrow.mjs +24 -0
  122. package/lib/eyebrow.test.mjs +20 -0
  123. package/lib/front-sections.test.mjs +82 -0
  124. package/lib/heading-levels.d.mts +7 -0
  125. package/lib/heading-levels.mjs +16 -0
  126. package/lib/heading-levels.test.mjs +37 -0
  127. package/lib/inline.ts +3 -2
  128. package/lib/markdown.ts +2 -0
  129. package/lib/materialIcon.d.mts +21 -0
  130. package/lib/materialIcon.mjs +29 -0
  131. package/lib/materialIcon.test.mjs +45 -0
  132. package/lib/menu-layout.d.mts +11 -0
  133. package/lib/menu-layout.mjs +14 -0
  134. package/lib/menu-layout.test.mjs +23 -0
  135. package/lib/menu.d.mts +59 -0
  136. package/lib/menu.mjs +129 -0
  137. package/lib/menu.test.mjs +291 -0
  138. package/lib/rehype-emphasize.d.mts +4 -0
  139. package/lib/rehype-emphasize.mjs +20 -0
  140. package/lib/rehype-emphasize.test.mjs +39 -0
  141. package/lib/section-in-view.d.mts +8 -0
  142. package/lib/section-in-view.mjs +25 -0
  143. package/lib/section-in-view.test.mjs +35 -0
  144. package/lib/section-marker.d.mts +5 -0
  145. package/lib/section-marker.mjs +18 -0
  146. package/lib/section-marker.test.mjs +22 -0
  147. package/lib/section-ranges.d.mts +12 -0
  148. package/lib/section-ranges.mjs +43 -0
  149. package/lib/section-ranges.test.mjs +61 -0
  150. package/lib/sheet-stops.d.mts +68 -0
  151. package/lib/sheet-stops.mjs +198 -0
  152. package/lib/sheet-stops.test.mjs +155 -0
  153. package/lib/still-timer.d.mts +5 -0
  154. package/lib/still-timer.mjs +17 -0
  155. package/lib/still-timer.test.mjs +42 -0
  156. package/lib/text-sections.d.mts +10 -0
  157. package/lib/text-sections.mjs +45 -0
  158. package/lib/text-sections.test.mjs +78 -0
  159. package/lib/video.mjs +2 -1
  160. package/lib/without-h1.d.mts +1 -0
  161. package/lib/without-h1.mjs +7 -0
  162. package/lib/without-h1.test.mjs +26 -0
  163. package/lib/word-at.d.mts +6 -0
  164. package/lib/word-at.mjs +20 -0
  165. package/lib/word-at.test.mjs +19 -0
  166. package/package.json +3 -1
  167. package/pages/editor/version.json.js +22 -2
  168. package/scripts/already-checked.mjs +11 -0
  169. package/scripts/already-checked.test.mjs +18 -0
  170. package/scripts/build-settings-problems.mjs +27 -0
  171. package/scripts/build-settings-problems.test.mjs +35 -0
  172. package/scripts/check-content.mjs +42 -4
  173. package/scripts/check-worker.mjs +96 -4
  174. package/scripts/ci-build.mjs +85 -0
  175. package/scripts/ci-build.test.mjs +137 -0
  176. package/scripts/editor-roundtrip.mjs +62 -3
  177. package/scripts/editor-screens.mjs +21 -7
  178. package/scripts/git-env.mjs +6 -0
  179. package/scripts/step-command.mjs +34 -0
  180. package/scripts/step-command.test.mjs +50 -0
  181. package/scripts/text-problems.mjs +83 -0
  182. package/scripts/text-problems.test.mjs +74 -0
  183. package/scripts/worker-modules.mjs +11 -1
  184. package/site/breakpoints.d.mts +6 -0
  185. package/site/breakpoints.mjs +52 -0
  186. package/site/breakpoints.test.mjs +36 -0
  187. package/site/casing.d.mts +3 -0
  188. package/site/casing.mjs +26 -0
  189. package/site/content.config.ts +2 -0
  190. package/site/content.ts +2 -4
  191. package/site/defineSite.d.mts +1 -0
  192. package/site/defineSite.mjs +3 -0
  193. package/site/home.ts +3 -2
  194. package/site/icons.d.mts +116 -4
  195. package/site/icons.mjs +62 -6
  196. package/site/images.mjs +1 -1
  197. package/site/live-diff.d.mts +22 -0
  198. package/site/live-diff.mjs +98 -0
  199. package/site/live-diff.test.mjs +235 -0
  200. package/site/materialSymbols.d.mts +5 -0
  201. package/site/materialSymbols.mjs +19 -0
  202. package/site/sections.ts +1 -1
  203. package/site/theme.d.mts +1 -1
  204. package/site/theme.mjs +7 -1
  205. package/src/ActionParts.tsx +1 -1
  206. package/src/Advanced.tsx +101 -20
  207. package/src/App.tsx +47 -121
  208. package/src/AppList.tsx +1 -1
  209. package/src/BlockParts.tsx +3 -3
  210. package/src/Colors.tsx +1 -1
  211. package/src/CommunicationParts.tsx +1 -1
  212. package/src/ContainmentParts.tsx +80 -28
  213. package/src/Deleted.tsx +18 -4
  214. package/src/EditorFooter.tsx +14 -0
  215. package/src/EntryForm.tsx +75 -39
  216. package/src/FormFrame.tsx +3 -8
  217. package/src/Library.tsx +46 -51
  218. package/src/Licenses.tsx +27 -11
  219. package/src/MainDrawerParts.tsx +60 -0
  220. package/src/MainMenu.tsx +136 -0
  221. package/src/MarkdownEditor.tsx +175 -32
  222. package/src/Media.tsx +69 -16
  223. package/src/MediaTags.tsx +91 -0
  224. package/src/ModalSheetSample.tsx +47 -0
  225. package/src/NavigationParts.tsx +67 -21
  226. package/src/NavigationRestParts.tsx +1 -1
  227. package/src/Parts.tsx +71 -0
  228. package/src/PartsShown.tsx +1 -1
  229. package/src/PickerParts.tsx +2 -2
  230. package/src/Redirects.tsx +3 -2
  231. package/src/Screen.tsx +93 -90
  232. package/src/SelectionParts.tsx +22 -6
  233. package/src/SiteLogoLink.tsx +14 -0
  234. package/src/StartFab.tsx +22 -0
  235. package/src/StartStatus.tsx +1 -1
  236. package/src/autoTags.d.mts +28 -0
  237. package/src/autoTags.mjs +68 -0
  238. package/src/autoTags.test.mjs +109 -0
  239. package/src/buildSettings.test.mjs +20 -0
  240. package/src/buildSettings.ts +20 -0
  241. package/src/checkSite.ts +14 -1
  242. package/src/deploy.ts +7 -9
  243. package/src/editor.css +150 -17
  244. package/src/eyebrow.d.mts +5 -0
  245. package/src/eyebrow.mjs +53 -0
  246. package/src/eyebrowState.d.mts +21 -0
  247. package/src/eyebrowState.mjs +62 -0
  248. package/src/eyebrowState.test.mjs +78 -0
  249. package/src/fileRecords.d.mts +115 -0
  250. package/src/fileRecords.mjs +224 -0
  251. package/src/fileRecords.test.mjs +224 -0
  252. package/src/fileTagFilter.d.mts +30 -0
  253. package/src/fileTagFilter.mjs +44 -0
  254. package/src/fileTagFilter.test.mjs +57 -0
  255. package/src/fileTags.d.mts +56 -0
  256. package/src/fileTags.mjs +100 -0
  257. package/src/fileTags.test.mjs +127 -0
  258. package/src/firstHeading.d.mts +6 -0
  259. package/src/firstHeading.mjs +37 -0
  260. package/src/i18n/da-DK/advanced.ts +27 -12
  261. package/src/i18n/da-DK/app.ts +9 -7
  262. package/src/i18n/da-DK/apps.ts +16 -16
  263. package/src/i18n/da-DK/blockSettings.ts +9 -9
  264. package/src/i18n/da-DK/blocks.ts +2 -2
  265. package/src/i18n/da-DK/check.ts +5 -2
  266. package/src/i18n/da-DK/cms.ts +14 -12
  267. package/src/i18n/da-DK/colors.ts +5 -5
  268. package/src/i18n/da-DK/common.ts +2 -2
  269. package/src/i18n/da-DK/company.ts +3 -3
  270. package/src/i18n/da-DK/consents.ts +6 -6
  271. package/src/i18n/da-DK/forms.ts +6 -6
  272. package/src/i18n/da-DK/history.ts +6 -6
  273. package/src/i18n/da-DK/keywords.ts +2 -2
  274. package/src/i18n/da-DK/library.ts +17 -15
  275. package/src/i18n/da-DK/licenses.ts +4 -4
  276. package/src/i18n/da-DK/media.ts +10 -4
  277. package/src/i18n/da-DK/parts.ts +62 -25
  278. package/src/i18n/da-DK/preview.ts +5 -6
  279. package/src/i18n/da-DK/redirects.ts +7 -7
  280. package/src/i18n/da-DK/seo.ts +1 -1
  281. package/src/i18n/da-DK/text.ts +2 -0
  282. package/src/i18n/da-DK/theme.ts +5 -5
  283. package/src/i18n/en-US/advanced.ts +34 -18
  284. package/src/i18n/en-US/app.ts +7 -5
  285. package/src/i18n/en-US/apps.ts +9 -9
  286. package/src/i18n/en-US/blockSettings.ts +4 -4
  287. package/src/i18n/en-US/blocks.ts +6 -6
  288. package/src/i18n/en-US/check.ts +6 -3
  289. package/src/i18n/en-US/cms.ts +15 -14
  290. package/src/i18n/en-US/colors.ts +18 -18
  291. package/src/i18n/en-US/common.ts +3 -3
  292. package/src/i18n/en-US/consents.ts +4 -4
  293. package/src/i18n/en-US/forms.ts +5 -5
  294. package/src/i18n/en-US/history.ts +2 -2
  295. package/src/i18n/en-US/library.ts +21 -15
  296. package/src/i18n/en-US/licenses.ts +6 -6
  297. package/src/i18n/en-US/media.ts +7 -1
  298. package/src/i18n/en-US/parts.ts +60 -21
  299. package/src/i18n/en-US/preview.ts +5 -6
  300. package/src/i18n/en-US/redirects.ts +3 -3
  301. package/src/i18n/en-US/seo.ts +1 -1
  302. package/src/i18n/en-US/text.ts +3 -0
  303. package/src/i18n/en-US/theme.ts +6 -6
  304. package/src/icon.tsx +90 -27
  305. package/src/licenseData.test.mjs +28 -0
  306. package/src/licenseData.ts +12 -1
  307. package/src/markdown.d.mts +2 -0
  308. package/src/markdown.mjs +73 -5
  309. package/src/markdown.test.mjs +74 -0
  310. package/src/materialIcons.ts +8 -0
  311. package/src/menuChildren.ts +36 -0
  312. package/src/menuOpen.ts +34 -0
  313. package/src/pagePreview.tsx +23 -19
  314. package/src/r2Caching.ts +14 -0
  315. package/src/sectionFrame.ts +39 -0
  316. package/src/sectionState.d.mts +19 -0
  317. package/src/sectionState.mjs +31 -0
  318. package/src/sectionState.test.mjs +59 -0
  319. package/src/sections.d.mts +9 -7
  320. package/src/sections.mjs +74 -27
  321. package/src/sections.test.mjs +59 -0
  322. package/src/sidebar.ts +2 -2
  323. package/src/site.ts +27 -17
  324. package/src/stillCursor.d.mts +8 -0
  325. package/src/stillCursor.mjs +19 -0
  326. package/src/stillCursor.test.mjs +26 -0
  327. package/src/tagUses.d.mts +47 -0
  328. package/src/tagUses.mjs +121 -0
  329. package/src/tagUses.test.mjs +141 -0
  330. package/src/themeUi.ts +10 -0
  331. package/src/topBarSlot.tsx +37 -0
  332. package/src/ui.tsx +654 -150
  333. package/src/virtual.d.ts +5 -0
  334. package/starter/README.md +2 -1
  335. package/starter/package.json +2 -1
  336. package/starter/wrangler.jsonc +3 -2
  337. package/theme/blocks/cards/LinkCards.tsx +2 -1
  338. package/theme/blocks/hero/View.tsx +2 -1
  339. package/theme/blocks/video/View.tsx +2 -1
  340. package/theme/components/BlogIndex.astro +2 -2
  341. package/theme/components/BlogPosts.astro +1 -1
  342. package/theme/components/BlogPosts.tsx +9 -6
  343. package/theme/components/Container.astro +1 -1
  344. package/theme/components/CookieConsent.astro +1 -1
  345. package/theme/components/EndBlock.astro +1 -1
  346. package/theme/components/Footer.astro +5 -5
  347. package/theme/components/Header.astro +3 -3
  348. package/theme/components/Icon.astro +2 -2
  349. package/theme/components/MobileMenu.tsx +72 -61
  350. package/theme/components/MobileNav.astro +2 -2
  351. package/theme/components/PageHeader.astro +2 -2
  352. package/theme/components/PageSection.astro +1 -1
  353. package/theme/components/PopupButton.tsx +2 -2
  354. package/theme/components/PopupView.tsx +2 -2
  355. package/theme/components/PostList.tsx +7 -3
  356. package/theme/components/RichText.astro +22 -7
  357. package/theme/components/Section.astro +17 -10
  358. package/theme/components/SectionHeading.tsx +1 -1
  359. package/theme/components/home/PageBlock.astro +4 -4
  360. package/theme/components/home/Text.astro +3 -3
  361. package/theme/layouts/PageLayout.astro +3 -3
  362. package/theme/layouts/PostLayout.astro +9 -8
  363. package/theme/styles/base.css +13 -3
  364. package/theme/styles/blocks.css +97 -10
  365. package/theme/styles/components.css +9 -9
  366. package/theme/styles/elevation.test.mjs +168 -0
  367. package/theme/styles/global.css +65 -59
  368. package/theme/styles/ui.css +460 -131
  369. package/theme/templates/Home.astro +1 -1
  370. package/theme/templates/New.astro +5 -5
  371. package/theme/templates/NotFound.astro +2 -2
  372. package/theme/ui/BottomSheet.tsx +118 -21
  373. package/theme/ui/BottomSheetResizeIcon.tsx +13 -0
  374. package/theme/ui/Button.tsx +4 -3
  375. package/theme/ui/Dialog.tsx +1 -1
  376. package/theme/ui/FabMenu.tsx +13 -12
  377. package/theme/ui/LoadingIndicator.tsx +1 -1
  378. package/theme/ui/NavigationDrawer.tsx +116 -15
  379. package/theme/ui/NavigationRail.tsx +22 -13
  380. package/theme/ui/Search.tsx +1 -1
  381. package/theme/ui/SettingRow.tsx +31 -0
  382. package/theme/ui/Switch.tsx +11 -11
  383. package/theme/ui/TextField.tsx +17 -6
  384. package/theme/ui/bottomSheet.script.ts +3 -2
  385. package/theme/ui/bottomSheetControl.ts +131 -46
  386. package/theme/ui/fabMenu.script.ts +69 -53
  387. package/theme/ui/modalSheetSize.ts +214 -0
  388. package/theme/ui/navigationDrawerControl.ts +35 -0
  389. package/theme/ui/search.script.ts +4 -2
  390. package/worker/index.js +3 -0
  391. package/worker/plan-status.js +46 -0
  392. package/worker/push.js +7 -5
  393. package/worker/r2-status.js +2 -1
  394. package/icons/lucide/desktop.svg +0 -1
  395. package/icons/material-symbols/desktop.svg +0 -1
  396. package/icons/phosphor/desktop.svg +0 -1
@@ -0,0 +1,43 @@
1
+ // Which top-level parts of a page's or blog post's text form H2 sections, for the editor's dotted frame.
2
+ // Exposes sectionRanges, sectionPart and partKind. Pure, no dependencies (used by src/sectionFrame.ts).
3
+
4
+ /** The kind of a part, from its ProseMirror node name and heading level; the label is the eyebrow line before a heading. */
5
+ export function partKind(typeName, level) {
6
+ if (typeName === 'heading') return level === 1 ? 'h1' : level === 2 ? 'h2' : 'other';
7
+ return typeName === 'eyebrow' ? 'label' : 'other';
8
+ }
9
+
10
+ /**
11
+ * The sections of a text: each H2 (with a label directly before it) and every part after it up to the next H2, the next H1
12
+ * or the end. `parts` is the top-level parts in order; the result has the index of each section's first and last part.
13
+ */
14
+ export function sectionRanges(parts) {
15
+ const ranges = [];
16
+ let from = -1;
17
+ const close = (to) => {
18
+ if (from >= 0 && to >= from) ranges.push({ from, to });
19
+ from = -1;
20
+ };
21
+ parts.forEach((part, i) => {
22
+ const next = parts[i + 1]?.kind;
23
+ if (part.kind === 'label') {
24
+ if (next === 'h2') {
25
+ close(i - 1);
26
+ from = i;
27
+ } else if (next === 'h1') close(i - 1);
28
+ } else if (part.kind === 'h2') {
29
+ if (parts[i - 1]?.kind !== 'label') {
30
+ close(i - 1);
31
+ from = i;
32
+ }
33
+ } else if (part.kind === 'h1') close(i - 1);
34
+ });
35
+ close(parts.length - 1);
36
+ return ranges;
37
+ }
38
+
39
+ /** Where a part is in its section: 'start', 'middle', 'end', or 'only' for a section of one part. */
40
+ export function sectionPart(index, { from, to }) {
41
+ if (from === to) return 'only';
42
+ return index === from ? 'start' : index === to ? 'end' : 'middle';
43
+ }
@@ -0,0 +1,61 @@
1
+ // Tests lib/section-ranges.mjs: which top-level parts of a text form H2 sections (the editor's dotted frame).
2
+ // Run with `node --test packages/editor/lib/*.test.mjs` (part of npm run check).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { partKind, sectionPart, sectionRanges } from './section-ranges.mjs';
6
+
7
+ // '1 2 l o' -> h1, h2, label, other
8
+ const KINDS = { 1: 'h1', 2: 'h2', l: 'label', o: 'other' };
9
+ const ranges = (text) =>
10
+ sectionRanges(
11
+ text
12
+ .split(' ')
13
+ .filter(Boolean)
14
+ .map((c) => ({ kind: KINDS[c] })),
15
+ );
16
+
17
+ test('no parts, no sections', () => assert.deepEqual(ranges(''), []));
18
+ test('no H2, no sections', () => assert.deepEqual(ranges('1 o o'), []));
19
+ test('an H2 at the start runs to the end', () => assert.deepEqual(ranges('2 o o'), [{ from: 0, to: 2 }]));
20
+ test('text before the first H2 is outside', () => assert.deepEqual(ranges('o 2 o'), [{ from: 1, to: 2 }]));
21
+ test('an H2 as the last part is a section of one', () => assert.deepEqual(ranges('o o 2'), [{ from: 2, to: 2 }]));
22
+ test('an H1 ends a section', () =>
23
+ assert.deepEqual(ranges('2 o 1 o 2 o'), [
24
+ { from: 0, to: 1 },
25
+ { from: 4, to: 5 },
26
+ ]));
27
+ test('consecutive H2s are a section each', () =>
28
+ assert.deepEqual(ranges('2 2 o 2'), [
29
+ { from: 0, to: 0 },
30
+ { from: 1, to: 2 },
31
+ { from: 3, to: 3 },
32
+ ]));
33
+ test('a label before an H2 starts the section', () => assert.deepEqual(ranges('o l 2 o'), [{ from: 1, to: 3 }]));
34
+ test('a label before an H2 ends the section above it', () =>
35
+ assert.deepEqual(ranges('2 o l 2 o'), [
36
+ { from: 0, to: 1 },
37
+ { from: 2, to: 4 },
38
+ ]));
39
+ test('a label before an H1 belongs to the H1', () => assert.deepEqual(ranges('2 o l 1 o'), [{ from: 0, to: 1 }]));
40
+ test('a label at the start before an H1 starts nothing', () => assert.deepEqual(ranges('l 1 2 o'), [{ from: 2, to: 3 }]));
41
+ test('a label not before a heading is an ordinary part', () => {
42
+ assert.deepEqual(ranges('2 l o'), [{ from: 0, to: 2 }]);
43
+ assert.deepEqual(ranges('l o 2'), [{ from: 2, to: 2 }]);
44
+ assert.deepEqual(ranges('2 o l'), [{ from: 0, to: 2 }]);
45
+ });
46
+
47
+ test('sectionPart tells where a part is in its section', () => {
48
+ assert.equal(sectionPart(3, { from: 3, to: 3 }), 'only');
49
+ assert.equal(sectionPart(1, { from: 1, to: 4 }), 'start');
50
+ assert.equal(sectionPart(2, { from: 1, to: 4 }), 'middle');
51
+ assert.equal(sectionPart(4, { from: 1, to: 4 }), 'end');
52
+ });
53
+
54
+ test('partKind reads a node name and heading level', () => {
55
+ assert.equal(partKind('heading', 1), 'h1');
56
+ assert.equal(partKind('heading', 2), 'h2');
57
+ assert.equal(partKind('heading', 3), 'other');
58
+ assert.equal(partKind('eyebrow'), 'label');
59
+ assert.equal(partKind('paragraph'), 'other');
60
+ assert.equal(partKind('siteBlock'), 'other');
61
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Material 3's states for a standard sheet: 'hidden' first when it can be closed.
3
+ * @param {boolean} closable
4
+ * @returns {SheetState[]}
5
+ */
6
+ export function defaultStates(closable: boolean): SheetState[];
7
+ /**
8
+ * More than one state other than 'hidden'.
9
+ * @param {SheetState[]} states
10
+ * @returns {boolean}
11
+ */
12
+ export function isResizable(states: SheetState[]): boolean;
13
+ /**
14
+ * The stops a sheet rests in, lowest first, from the measured heights (px) and the allowed states.
15
+ * half-expanded is kept only when more than 48px from both neighbours; a stop within 1px of the one before is dropped.
16
+ * @param {{ collapsed: number, 'half-expanded': number, expanded: number }} heights
17
+ * @param {SheetState[]} states
18
+ * @returns {Stop[]}
19
+ */
20
+ export function restingStops(heights: {
21
+ collapsed: number;
22
+ "half-expanded": number;
23
+ expanded: number;
24
+ }, states: SheetState[]): Stop[];
25
+ /**
26
+ * The state a tap on the handle (or the resize button) moves to: the next higher stop, never 'hidden';
27
+ * from the highest, or from a state that is not a stop, the lowest.
28
+ * @param {Stop[]} stops
29
+ * @param {SheetState} current
30
+ * @returns {SheetState}
31
+ */
32
+ export function nextOnTap(stops: Stop[], current: SheetState): SheetState;
33
+ /**
34
+ * The state a drag comes to rest in: a flick (over 0.4 px/ms) moves on to the next stop that way,
35
+ * otherwise the nearest (a tie goes to the lower).
36
+ * @param {Stop[]} stops
37
+ * @param {number} height - the sheet's height in px when let go
38
+ * @param {number} velocity - px/ms, positive upward
39
+ * @returns {SheetState}
40
+ */
41
+ export function restAfterDrag(stops: Stop[], height: number, velocity: number): SheetState;
42
+ /**
43
+ * The start height of a modal sheet: the smaller of its content height and the allowed space,
44
+ * which is the full viewport minus a gap, or half the viewport.
45
+ * @param {number} content
46
+ * @param {number} viewport
47
+ * @param {'half' | 'full'} startAt
48
+ * @param {number} topGap
49
+ * @returns {number}
50
+ */
51
+ export function modalStartHeight(content: number, viewport: number, startAt: "half" | "full", topGap: number): number;
52
+ /**
53
+ * The rest place of a modal sheet after a drag: full, start, or close.
54
+ * A flick (over 0.4 px/ms) moves to full or to start/close depending on the height.
55
+ * Otherwise the nearest stop, with ties going to the higher stop.
56
+ * @param {number} start
57
+ * @param {number} full
58
+ * @param {number} height
59
+ * @param {number} velocity
60
+ * @returns {ModalRest}
61
+ */
62
+ export function modalRestAfterDrag(start: number, full: number, height: number, velocity: number): ModalRest;
63
+ export type SheetState = "collapsed" | "half-expanded" | "expanded" | "hidden";
64
+ export type Stop = {
65
+ state: SheetState;
66
+ height: number;
67
+ };
68
+ export type ModalRest = "start" | "full" | "close";
@@ -0,0 +1,198 @@
1
+ // A bottom sheet's height decisions (bottomSheetControl.ts uses them): the states it rests in, the next one on a tap,
2
+ // the one a drag comes to rest in, and a modal sheet's start height and resting place. Pure, so it is tested
3
+ // (sheet-stops.test.mjs). Exports defaultStates, isResizable, restingStops, nextOnTap, restAfterDrag, modalStartHeight
4
+ // and modalRestAfterDrag. No dependencies.
5
+
6
+ /** @typedef {'collapsed' | 'half-expanded' | 'expanded' | 'hidden'} SheetState */
7
+ /** @typedef {{ state: SheetState, height: number }} Stop */
8
+ /** @typedef {'start' | 'full' | 'close'} ModalRest */
9
+
10
+ const stateOrder = ['hidden', 'collapsed', 'half-expanded', 'expanded'];
11
+
12
+ /**
13
+ * Material 3's states for a standard sheet: 'hidden' first when it can be closed.
14
+ * @param {boolean} closable
15
+ * @returns {SheetState[]}
16
+ */
17
+ export function defaultStates(closable) {
18
+ if (closable) {
19
+ return ['hidden', 'collapsed', 'half-expanded', 'expanded'];
20
+ }
21
+ return ['collapsed', 'half-expanded', 'expanded'];
22
+ }
23
+
24
+ /**
25
+ * More than one state other than 'hidden'.
26
+ * @param {SheetState[]} states
27
+ * @returns {boolean}
28
+ */
29
+ export function isResizable(states) {
30
+ const count = states.filter((s) => s !== 'hidden').length;
31
+ return count > 1;
32
+ }
33
+
34
+ /**
35
+ * The stops a sheet rests in, lowest first, from the measured heights (px) and the allowed states.
36
+ * half-expanded is kept only when more than 48px from both neighbours; a stop within 1px of the one before is dropped.
37
+ * @param {{ collapsed: number, 'half-expanded': number, expanded: number }} heights
38
+ * @param {SheetState[]} states
39
+ * @returns {Stop[]}
40
+ */
41
+ export function restingStops(heights, states) {
42
+ const stops = states.map((state) => {
43
+ const height = state === 'hidden' ? 0 : heights[state];
44
+ return { state, height };
45
+ });
46
+
47
+ stops.sort((a, b) => {
48
+ if (a.height !== b.height) {
49
+ return a.height - b.height;
50
+ }
51
+ const indexA = stateOrder.indexOf(a.state);
52
+ const indexB = stateOrder.indexOf(b.state);
53
+ return indexA - indexB;
54
+ });
55
+
56
+ // Filter half-expanded if it is not distinct enough
57
+ const filtered = stops.filter((stop, index, arr) => {
58
+ if (stop.state !== 'half-expanded') return true;
59
+
60
+ const prev = arr[index - 1];
61
+ const next = arr[index + 1];
62
+
63
+ const prevOk = !prev || stop.height - prev.height > 48;
64
+ const nextOk = !next || next.height - stop.height > 48;
65
+
66
+ return prevOk && nextOk;
67
+ });
68
+
69
+ // Walk the list and drop stops within 1px of the previous kept stop
70
+ /** @type {Stop[]} */
71
+ const result = [];
72
+ for (const stop of filtered) {
73
+ if (result.length === 0) {
74
+ result.push(stop);
75
+ } else {
76
+ const lastKept = result[result.length - 1];
77
+ if (Math.abs(stop.height - lastKept.height) > 1) {
78
+ result.push(stop);
79
+ }
80
+ }
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * The state a tap on the handle (or the resize button) moves to: the next higher stop, never 'hidden';
88
+ * from the highest, or from a state that is not a stop, the lowest.
89
+ * @param {Stop[]} stops
90
+ * @param {SheetState} current
91
+ * @returns {SheetState}
92
+ */
93
+ export function nextOnTap(stops, current) {
94
+ const index = stops.findIndex((s) => s.state === current);
95
+
96
+ if (index === -1) {
97
+ const nonHidden = stops.filter((s) => s.state !== 'hidden');
98
+ return nonHidden.length > 0 ? nonHidden[0].state : stops[0].state;
99
+ }
100
+
101
+ let nextIndex = index + 1;
102
+ while (nextIndex < stops.length && stops[nextIndex].state === 'hidden') {
103
+ nextIndex++;
104
+ }
105
+
106
+ if (nextIndex < stops.length) {
107
+ return stops[nextIndex].state;
108
+ }
109
+
110
+ const nonHidden = stops.filter((s) => s.state !== 'hidden');
111
+ if (nonHidden.length > 0) {
112
+ return nonHidden[0].state;
113
+ }
114
+
115
+ return stops[0].state;
116
+ }
117
+
118
+ /**
119
+ * The state a drag comes to rest in: a flick (over 0.4 px/ms) moves on to the next stop that way,
120
+ * otherwise the nearest (a tie goes to the lower).
121
+ * @param {Stop[]} stops
122
+ * @param {number} height - the sheet's height in px when let go
123
+ * @param {number} velocity - px/ms, positive upward
124
+ * @returns {SheetState}
125
+ */
126
+ export function restAfterDrag(stops, height, velocity) {
127
+ if (velocity > 0.4) {
128
+ const higher = stops.find((s) => s.height > height + 1);
129
+ if (higher) return higher.state;
130
+ } else if (velocity < -0.4) {
131
+ const lower = [...stops].reverse().find((s) => s.height < height - 1);
132
+ if (lower) return lower.state;
133
+ }
134
+
135
+ let best = stops[0];
136
+ let bestDiff = Math.abs(stops[0].height - height);
137
+ for (const s of stops) {
138
+ const diff = Math.abs(s.height - height);
139
+ if (diff < bestDiff) {
140
+ best = s;
141
+ bestDiff = diff;
142
+ }
143
+ }
144
+ return best.state;
145
+ }
146
+
147
+ /**
148
+ * The start height of a modal sheet: the smaller of its content height and the allowed space,
149
+ * which is the full viewport minus a gap, or half the viewport.
150
+ * @param {number} content
151
+ * @param {number} viewport
152
+ * @param {'half' | 'full'} startAt
153
+ * @param {number} topGap
154
+ * @returns {number}
155
+ */
156
+ export function modalStartHeight(content, viewport, startAt, topGap) {
157
+ if (startAt === 'full') {
158
+ return Math.min(content, viewport - topGap);
159
+ }
160
+ return Math.min(content, viewport / 2);
161
+ }
162
+
163
+ /**
164
+ * The rest place of a modal sheet after a drag: full, start, or close.
165
+ * A flick (over 0.4 px/ms) moves to full or to start/close depending on the height.
166
+ * Otherwise the nearest stop, with ties going to the higher stop.
167
+ * @param {number} start
168
+ * @param {number} full
169
+ * @param {number} height
170
+ * @param {number} velocity
171
+ * @returns {ModalRest}
172
+ */
173
+ export function modalRestAfterDrag(start, full, height, velocity) {
174
+ if (velocity > 0.4) {
175
+ return 'full';
176
+ }
177
+ if (velocity < -0.4) {
178
+ return height < start - 1 ? 'close' : 'start';
179
+ }
180
+
181
+ // Nearest of close (0), start, and full. Tie goes to the higher one.
182
+ // Order of preference: full, start, close.
183
+ let best = 'close';
184
+ let bestDiff = height; // distance to close (0)
185
+
186
+ const startDiff = Math.abs(start - height);
187
+ if (startDiff <= bestDiff) {
188
+ best = 'start';
189
+ bestDiff = startDiff;
190
+ }
191
+
192
+ const fullDiff = Math.abs(full - height);
193
+ if (fullDiff <= bestDiff) {
194
+ best = 'full';
195
+ }
196
+
197
+ return best;
198
+ }
@@ -0,0 +1,155 @@
1
+ // Tests of sheet-stops.mjs: the states a bottom sheet rests in, the next one on a tap and after a drag, a modal's heights.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ defaultStates,
6
+ isResizable,
7
+ restingStops,
8
+ nextOnTap,
9
+ restAfterDrag,
10
+ modalStartHeight,
11
+ modalRestAfterDrag,
12
+ } from './sheet-stops.mjs';
13
+
14
+ const heights = { collapsed: 100, 'half-expanded': 400, expanded: 700 };
15
+
16
+ test('defaultStates(false) returns collapsed, half-expanded, expanded', () => {
17
+ assert.deepEqual(defaultStates(false), ['collapsed', 'half-expanded', 'expanded']);
18
+ });
19
+
20
+ test('defaultStates(true) returns hidden, collapsed, half-expanded, expanded', () => {
21
+ assert.deepEqual(defaultStates(true), ['hidden', 'collapsed', 'half-expanded', 'expanded']);
22
+ });
23
+
24
+ test('isResizable returns false for single non-hidden state', () => {
25
+ assert.equal(isResizable(['expanded']), false);
26
+ });
27
+
28
+ test('isResizable returns false for hidden and one other', () => {
29
+ assert.equal(isResizable(['hidden', 'expanded']), false);
30
+ });
31
+
32
+ test('isResizable returns true for two non-hidden states', () => {
33
+ assert.equal(isResizable(['collapsed', 'expanded']), true);
34
+ });
35
+
36
+ test('restingStops with default states (false)', () => {
37
+ assert.deepEqual(restingStops(heights, defaultStates(false)), [
38
+ { state: 'collapsed', height: 100 },
39
+ { state: 'half-expanded', height: 400 },
40
+ { state: 'expanded', height: 700 },
41
+ ]);
42
+ });
43
+
44
+ test('restingStops with default states (true) includes hidden', () => {
45
+ const stops = restingStops(heights, defaultStates(true));
46
+ assert.equal(stops.length, 4);
47
+ assert.deepEqual(stops[0], { state: 'hidden', height: 0 });
48
+ assert.deepEqual(stops[1], { state: 'collapsed', height: 100 });
49
+ assert.deepEqual(stops[2], { state: 'half-expanded', height: 400 });
50
+ assert.deepEqual(stops[3], { state: 'expanded', height: 700 });
51
+ });
52
+
53
+ test('restingStops with single expanded state', () => {
54
+ assert.deepEqual(restingStops(heights, ['expanded']), [{ state: 'expanded', height: 700 }]);
55
+ });
56
+
57
+ test('restingStops filters half-expanded if too close to collapsed', () => {
58
+ const h = { collapsed: 100, 'half-expanded': 130, expanded: 700 };
59
+ assert.deepEqual(restingStops(h, defaultStates(false)), [
60
+ { state: 'collapsed', height: 100 },
61
+ { state: 'expanded', height: 700 },
62
+ ]);
63
+ });
64
+
65
+ test('restingStops filters expanded if too close to collapsed', () => {
66
+ const h = { collapsed: 100, 'half-expanded': 400, expanded: 100 };
67
+ assert.deepEqual(restingStops(h, ['collapsed', 'expanded']), [{ state: 'collapsed', height: 100 }]);
68
+ });
69
+
70
+ test('nextOnTap cycles through states', () => {
71
+ const stops = restingStops(heights, defaultStates(false));
72
+ assert.equal(nextOnTap(stops, 'collapsed'), 'half-expanded');
73
+ assert.equal(nextOnTap(stops, 'half-expanded'), 'expanded');
74
+ assert.equal(nextOnTap(stops, 'expanded'), 'collapsed');
75
+ });
76
+
77
+ test('nextOnTap with hidden stops returns collapsed when at expanded', () => {
78
+ const stops = restingStops(heights, defaultStates(true));
79
+ assert.equal(nextOnTap(stops, 'expanded'), 'collapsed');
80
+ });
81
+
82
+ test('nextOnTap with hidden not a stop returns collapsed', () => {
83
+ const stops = restingStops(heights, defaultStates(false));
84
+ assert.equal(nextOnTap(stops, 'hidden'), 'collapsed');
85
+ });
86
+
87
+ test('nextOnTap with single stop returns that stop', () => {
88
+ const stops = restingStops(heights, ['expanded']);
89
+ assert.equal(nextOnTap(stops, 'expanded'), 'expanded');
90
+ });
91
+
92
+ test('restAfterDrag with zero velocity chooses nearest', () => {
93
+ const stops = restingStops(heights, defaultStates(false));
94
+ assert.equal(restAfterDrag(stops, 380, 0), 'half-expanded');
95
+ assert.equal(restAfterDrag(stops, 250, 0), 'collapsed');
96
+ });
97
+
98
+ test('restAfterDrag with positive velocity chooses higher', () => {
99
+ const stops = restingStops(heights, defaultStates(false));
100
+ assert.equal(restAfterDrag(stops, 150, 0.8), 'half-expanded');
101
+ });
102
+
103
+ test('restAfterDrag with negative velocity chooses lower', () => {
104
+ const stops = restingStops(heights, defaultStates(false));
105
+ assert.equal(restAfterDrag(stops, 650, -0.8), 'half-expanded');
106
+ });
107
+
108
+ test('restAfterDrag with high velocity and no higher stop chooses nearest', () => {
109
+ const stops = restingStops(heights, defaultStates(false));
110
+ assert.equal(restAfterDrag(stops, 720, 0.8), 'expanded');
111
+ });
112
+
113
+ test('modalStartHeight returns content if less than half viewport', () => {
114
+ assert.equal(modalStartHeight(300, 800, 'half', 72), 300);
115
+ });
116
+
117
+ test('modalStartHeight returns half viewport if content is larger', () => {
118
+ assert.equal(modalStartHeight(600, 800, 'half', 72), 400);
119
+ });
120
+
121
+ test('modalStartHeight returns viewport minus gap if content is larger and full', () => {
122
+ assert.equal(modalStartHeight(900, 800, 'full', 72), 728);
123
+ });
124
+
125
+ test('modalStartHeight returns content if less than viewport minus gap and full', () => {
126
+ assert.equal(modalStartHeight(300, 800, 'full', 72), 300);
127
+ });
128
+
129
+ test('modalRestAfterDrag with zero velocity and height near start returns start', () => {
130
+ assert.equal(modalRestAfterDrag(400, 728, 420, 0), 'start');
131
+ });
132
+
133
+ test('modalRestAfterDrag with zero velocity and height near full returns full', () => {
134
+ assert.equal(modalRestAfterDrag(400, 728, 600, 0), 'full');
135
+ });
136
+
137
+ test('modalRestAfterDrag with zero velocity and height near close returns close', () => {
138
+ assert.equal(modalRestAfterDrag(400, 728, 150, 0), 'close');
139
+ });
140
+
141
+ test('modalRestAfterDrag with zero velocity and tie between close and start returns start', () => {
142
+ assert.equal(modalRestAfterDrag(400, 728, 200, 0), 'start');
143
+ });
144
+
145
+ test('modalRestAfterDrag with positive velocity returns full', () => {
146
+ assert.equal(modalRestAfterDrag(400, 728, 420, 0.8), 'full');
147
+ });
148
+
149
+ test('modalRestAfterDrag with negative velocity and low height returns close', () => {
150
+ assert.equal(modalRestAfterDrag(400, 728, 380, -0.8), 'close');
151
+ });
152
+
153
+ test('modalRestAfterDrag with negative velocity and high height returns start', () => {
154
+ assert.equal(modalRestAfterDrag(400, 728, 600, -0.8), 'start');
155
+ });
@@ -0,0 +1,5 @@
1
+ /** @param {number} delay ms @param {() => void} onStill @returns {{ restart(): void, stop(): void }} */
2
+ export function stillTimer(delay: number, onStill: () => void): {
3
+ restart(): void;
4
+ stop(): void;
5
+ };
@@ -0,0 +1,17 @@
1
+ // Waits for the cursor to stand still (the menu over a text at a still cursor, selectionMenu in MarkdownEditor.tsx):
2
+ // restart on every move or keypress, and onStill runs once it has been still for the delay. Exposes stillTimer.
3
+ // No dependencies, no browser.
4
+
5
+ /** @param {number} delay ms @param {() => void} onStill @returns {{ restart(): void, stop(): void }} */
6
+ export function stillTimer(delay, onStill) {
7
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
8
+ let timer;
9
+ const stop = () => clearTimeout(timer);
10
+ return {
11
+ restart() {
12
+ stop();
13
+ timer = setTimeout(onStill, delay);
14
+ },
15
+ stop,
16
+ };
17
+ }
@@ -0,0 +1,42 @@
1
+ // Tests for lib/still-timer.mjs: onStill once the cursor has stood still for the delay. Run: node --test packages/editor/lib/*.test.mjs packages/editor/src/*.test.mjs
2
+ import { mock, test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { stillTimer } from './still-timer.mjs';
5
+
6
+ test('calls onStill once after the delay', () => {
7
+ mock.timers.enable({ apis: ['setTimeout'] });
8
+ const onStill = mock.fn();
9
+ stillTimer(700, onStill).restart();
10
+ mock.timers.tick(699);
11
+ assert.equal(onStill.mock.callCount(), 0);
12
+ mock.timers.tick(1);
13
+ assert.equal(onStill.mock.callCount(), 1);
14
+ mock.timers.tick(5000);
15
+ assert.equal(onStill.mock.callCount(), 1);
16
+ mock.timers.reset();
17
+ });
18
+
19
+ test('a restart waits the whole delay again', () => {
20
+ mock.timers.enable({ apis: ['setTimeout'] });
21
+ const onStill = mock.fn();
22
+ const timer = stillTimer(700, onStill);
23
+ timer.restart();
24
+ mock.timers.tick(500);
25
+ timer.restart();
26
+ mock.timers.tick(500);
27
+ assert.equal(onStill.mock.callCount(), 0);
28
+ mock.timers.tick(200);
29
+ assert.equal(onStill.mock.callCount(), 1);
30
+ mock.timers.reset();
31
+ });
32
+
33
+ test('stop cancels it', () => {
34
+ mock.timers.enable({ apis: ['setTimeout'] });
35
+ const onStill = mock.fn();
36
+ const timer = stillTimer(700, onStill);
37
+ timer.restart();
38
+ timer.stop();
39
+ mock.timers.tick(1000);
40
+ assert.equal(onStill.mock.callCount(), 0);
41
+ mock.timers.reset();
42
+ });
@@ -0,0 +1,10 @@
1
+ /** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
2
+ * A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
3
+ * returned as it came, in one run.
4
+ * @param {string} html
5
+ * @returns {{ color: string | null, html: string }[]} */
6
+ export function sectionsOf(html: string): {
7
+ color: string | null;
8
+ html: string;
9
+ }[];
10
+ export function sectionClass(color: any): string;
@@ -0,0 +1,45 @@
1
+ // A page's or post's rendered text cut into runs for the site: a section with a colour (the marker before its H2,
2
+ // lib/section-marker.mjs) is one run from the marker to the next H2, H1 or the end. Exposes sectionsOf and sectionClass,
3
+ // used by theme/components/RichText.astro and src/pagePreview.tsx. Depends on section-marker.mjs.
4
+ import { SECTION_COLORS } from './section-marker.mjs';
5
+
6
+ // The marker as the site's HTML pipeline writes it (rehype-raw keeps it as written; a space before `>` is tolerated).
7
+ const MARKER = String.raw`<div data-section="([^"]*)"\s*><\/div>\s*`;
8
+ // A label line (text-menu's <p class="block-eyebrow">) directly before a heading starts the heading's section.
9
+ const LABEL = String.raw`(?:<p class="block-eyebrow">(?:(?!<\/p>)[\s\S])*<\/p>\s*)?`;
10
+ // A marker followed by an H2 (with its label between): where a coloured section starts. Ends just after `<h2`.
11
+ const START = new RegExp(`${MARKER}${LABEL}<h2\\b`, 'g');
12
+ // Where the next section (or an H1) starts: its marker and label if it has them, else its heading.
13
+ const NEXT = new RegExp(`(?:${MARKER})?${LABEL}<h[12]\\b`);
14
+ const MARKERS = new RegExp(MARKER, 'g');
15
+
16
+ const withoutMarkers = (html) => html.replace(MARKERS, '');
17
+
18
+ /** The text's HTML as runs: { color, html } with the marker removed; color is null for text outside a coloured section.
19
+ * A marker with an unknown colour, or not followed by an H2, is dropped. Without any coloured section, the HTML is
20
+ * returned as it came, in one run.
21
+ * @param {string} html
22
+ * @returns {{ color: string | null, html: string }[]} */
23
+ export function sectionsOf(html) {
24
+ const runs = [];
25
+ let from = 0;
26
+ const plain = (to) => {
27
+ const text = withoutMarkers(html.slice(from, to));
28
+ if (text.trim()) runs.push({ color: null, html: text });
29
+ };
30
+ for (const m of html.matchAll(START)) {
31
+ if (m.index < from || !SECTION_COLORS.includes(m[1])) continue;
32
+ const heading = m.index + m[0].length;
33
+ const next = html.slice(heading).search(NEXT);
34
+ const to = next < 0 ? html.length : heading + next;
35
+ plain(m.index);
36
+ runs.push({ color: m[1], html: withoutMarkers(html.slice(m.index, to)) });
37
+ from = to;
38
+ }
39
+ if (!runs.length) return [{ color: null, html: withoutMarkers(html) }];
40
+ plain(html.length);
41
+ return runs;
42
+ }
43
+
44
+ /** The classes of a coloured section on the site (blocks.css). */
45
+ export const sectionClass = (color) => `text-section text-section-${color}`;