@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,110 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import { TextSelection } from "@tiptap/pm/state";
6
+ import { createEditorExtensions } from "../extensions.js";
7
+ import { clearFormatting, toggleMarkAndExit } from "../bubble-menu.js";
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ function createEditor(content: string): Editor {
12
+ const element = document.createElement("div");
13
+ document.body.appendChild(element);
14
+
15
+ const editor = new Editor({
16
+ element,
17
+ extensions: createEditorExtensions(),
18
+ content,
19
+ });
20
+
21
+ editors.push(editor);
22
+ return editor;
23
+ }
24
+
25
+ function getBubbleMenu(editor: Editor): HTMLElement {
26
+ const el = (editor.options.element as HTMLElement)
27
+ .closest("body")
28
+ ?.querySelector<HTMLElement>(".tiptap-bubble-menu");
29
+ if (!el) throw new Error("Bubble menu was never created");
30
+ return el;
31
+ }
32
+
33
+ function isVisible(editor: Editor): boolean {
34
+ return getBubbleMenu(editor).style.display !== "none";
35
+ }
36
+
37
+ afterEach(() => {
38
+ while (editors.length) editors.pop()?.destroy();
39
+ document.body.innerHTML = "";
40
+ });
41
+
42
+ describe("bubble menu visibility", () => {
43
+ it("stays hidden while the selection is empty", () => {
44
+ const editor = createEditor("<p>A quiet note</p>");
45
+ editor.commands.focus();
46
+
47
+ expect(isVisible(editor)).toBe(false);
48
+ });
49
+
50
+ it("shows for a text selection", () => {
51
+ const editor = createEditor("<p>A quiet note</p>");
52
+ editor.view.dispatch(
53
+ editor.state.tr.setSelection(
54
+ TextSelection.create(editor.state.doc, 1, 8),
55
+ ),
56
+ );
57
+
58
+ expect(isVisible(editor)).toBe(true);
59
+ });
60
+
61
+ it("shows for select-all, which is an AllSelection rather than a text selection", () => {
62
+ const editor = createEditor("<p>First line</p><p>Second line</p>");
63
+ editor.commands.selectAll();
64
+
65
+ expect(isVisible(editor)).toBe(true);
66
+ });
67
+
68
+ it("stays hidden when select-all covers an empty document", () => {
69
+ const editor = createEditor("<p></p>");
70
+ editor.commands.selectAll();
71
+
72
+ expect(isVisible(editor)).toBe(false);
73
+ });
74
+
75
+ it("hides again once the selection collapses", () => {
76
+ const editor = createEditor("<p>A quiet note</p>");
77
+ editor.commands.selectAll();
78
+ expect(isVisible(editor)).toBe(true);
79
+
80
+ editor.commands.setTextSelection(2);
81
+
82
+ expect(isVisible(editor)).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("bubble menu actions on a document-wide selection", () => {
87
+ it("bolds the whole document and leaves the cursor outside the mark", () => {
88
+ const editor = createEditor("<p>First line</p><p>Second line</p>");
89
+ editor.commands.selectAll();
90
+
91
+ toggleMarkAndExit(editor, "bold");
92
+
93
+ expect(editor.getHTML()).toBe(
94
+ "<p><strong>First line</strong></p><p><strong>Second line</strong></p>",
95
+ );
96
+ expect(editor.state.selection.empty).toBe(true);
97
+ expect(editor.state.storedMarks ?? []).toHaveLength(0);
98
+ });
99
+
100
+ it("clears formatting across every selected block", () => {
101
+ const editor = createEditor(
102
+ "<h2>A heading</h2><p><strong>bold</strong> and <em>italic</em></p>",
103
+ );
104
+ editor.commands.selectAll();
105
+
106
+ clearFormatting(editor);
107
+
108
+ expect(editor.getHTML()).toBe("<p>A heading</p><p>bold and italic</p>");
109
+ });
110
+ });
@@ -1,7 +1,12 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
- import { getFloatingPosition } from "../floating-position.js";
4
+ import {
5
+ getFloatingArrowOffset,
6
+ getFloatingPosition,
7
+ getVisibleAnchorRect,
8
+ getVisibleClipRect,
9
+ } from "../floating-position.js";
5
10
 
6
11
  describe("getFloatingPosition", () => {
7
12
  const containerRect = {
@@ -72,3 +77,133 @@ describe("getFloatingPosition", () => {
72
77
  expect(layout.top).toBe(8);
73
78
  });
74
79
  });
80
+
81
+ describe("getVisibleAnchorRect", () => {
82
+ // A three-line selection in a text column running from x=100 to x=300,
83
+ // inside a scroll region showing y=50..250.
84
+ const clipRect = { left: 100, top: 50, width: 220, height: 200 };
85
+ const lines = [
86
+ { left: 180, right: 300, top: 60, bottom: 80 },
87
+ { left: 100, right: 300, top: 80, bottom: 100 },
88
+ { left: 100, right: 160, top: 100, bottom: 120 },
89
+ ];
90
+
91
+ it("centers on the whole selection but hugs its first line", () => {
92
+ const anchor = getVisibleAnchorRect(lines, clipRect);
93
+
94
+ expect(anchor).toEqual({ left: 100, right: 300, top: 60, bottom: 80 });
95
+ });
96
+
97
+ it("ignores an enclosing block rect when picking the first line", () => {
98
+ const anchor = getVisibleAnchorRect(
99
+ [{ left: 100, right: 300, top: 60, bottom: 120 }, ...lines],
100
+ clipRect,
101
+ );
102
+
103
+ expect(anchor?.bottom).toBe(80);
104
+ });
105
+
106
+ it("anchors to the first line still on screen when the selection scrolls past the top", () => {
107
+ const anchor = getVisibleAnchorRect(
108
+ [
109
+ { left: 100, right: 300, top: -40, bottom: -20 },
110
+ { left: 100, right: 300, top: -20, bottom: 0 },
111
+ ...lines,
112
+ ],
113
+ clipRect,
114
+ );
115
+
116
+ expect(anchor).toEqual({ left: 100, right: 300, top: 60, bottom: 80 });
117
+ });
118
+
119
+ it("clips a selection that overruns the scroll region", () => {
120
+ const anchor = getVisibleAnchorRect(
121
+ [{ left: 100, right: 300, top: 20, bottom: 400 }],
122
+ clipRect,
123
+ );
124
+
125
+ expect(anchor).toEqual({ left: 100, right: 300, top: 50, bottom: 250 });
126
+ });
127
+
128
+ it("returns null when nothing is visible", () => {
129
+ const anchor = getVisibleAnchorRect(
130
+ [{ left: 100, right: 300, top: 300, bottom: 320 }],
131
+ clipRect,
132
+ );
133
+
134
+ expect(anchor).toBeNull();
135
+ });
136
+ });
137
+
138
+ describe("getVisibleClipRect", () => {
139
+ function stubRect(
140
+ el: HTMLElement,
141
+ rect: { left: number; top: number; width: number; height: number },
142
+ ) {
143
+ const measured = {
144
+ ...rect,
145
+ right: rect.left + rect.width,
146
+ bottom: rect.top + rect.height,
147
+ x: rect.left,
148
+ y: rect.top,
149
+ toJSON: () => rect,
150
+ };
151
+ el.getBoundingClientRect = () => measured;
152
+ }
153
+
154
+ it("narrows the container down to a scrolling ancestor", () => {
155
+ const scroller = document.createElement("div");
156
+ scroller.style.overflowY = "auto";
157
+ const content = document.createElement("div");
158
+ scroller.appendChild(content);
159
+ document.body.appendChild(scroller);
160
+ stubRect(scroller, { left: 100, top: 120, width: 300, height: 200 });
161
+
162
+ const clip = getVisibleClipRect(content, {
163
+ left: 100,
164
+ top: 50,
165
+ width: 320,
166
+ height: 400,
167
+ });
168
+
169
+ expect(clip).toEqual({ left: 100, top: 120, width: 300, height: 200 });
170
+ scroller.remove();
171
+ });
172
+
173
+ it("keeps the container when no ancestor reports a clipping region", () => {
174
+ const plain = document.createElement("div");
175
+ const content = document.createElement("div");
176
+ plain.appendChild(content);
177
+ document.body.appendChild(plain);
178
+ const containerRect = { left: 100, top: 50, width: 320, height: 400 };
179
+
180
+ expect(getVisibleClipRect(content, containerRect)).toEqual(containerRect);
181
+ plain.remove();
182
+ });
183
+ });
184
+
185
+ describe("getFloatingArrowOffset", () => {
186
+ const containerRect = { left: 100, top: 50, width: 320, height: 240 };
187
+
188
+ it("points at the anchor center", () => {
189
+ const offset = getFloatingArrowOffset({
190
+ anchorRect: { left: 200, right: 240, top: 70, bottom: 90 },
191
+ containerRect,
192
+ floatingLeft: 60,
193
+ floatingWidth: 160,
194
+ });
195
+
196
+ expect(offset).toBe(60);
197
+ });
198
+
199
+ it("stays inside the surface when it is clamped away from the anchor", () => {
200
+ const offset = getFloatingArrowOffset({
201
+ anchorRect: { left: 400, right: 420, top: 70, bottom: 90 },
202
+ containerRect,
203
+ floatingLeft: 8,
204
+ floatingWidth: 160,
205
+ });
206
+
207
+ expect(offset).toBe(150);
208
+ });
209
+ });
@@ -5,9 +5,11 @@ import { Editor, type JSONContent } from "@tiptap/core";
5
5
  import StarterKit from "@tiptap/starter-kit";
6
6
  import { ImageNode } from "../image-node.js";
7
7
  import {
8
+ adoptPendingInlineImageUploads,
8
9
  rehostInlineImage,
9
10
  resolveInlineImageUrls,
10
11
  hasPendingInlineImagePlaceholders,
12
+ uploadAndInsertInlineImage,
11
13
  } from "../inline-image-upload.js";
12
14
 
13
15
  const editors: Editor[] = [];
@@ -141,3 +143,38 @@ describe("resolveInlineImageUrls + hasPendingInlineImagePlaceholders", () => {
141
143
  expect(resolved).toBe(json);
142
144
  });
143
145
  });
146
+
147
+ describe("adoptPendingInlineImageUploads", () => {
148
+ it("hands an in-flight upload on through a second adoption", async () => {
149
+ const first = createEditor();
150
+ let finishUpload: (value: { url: string }) => void = () => {};
151
+ const insert = uploadAndInsertInlineImage(
152
+ first,
153
+ new File(["image"], "shot.png", { type: "image/png" }),
154
+ () =>
155
+ new Promise((resolve) => {
156
+ finishUpload = resolve;
157
+ }),
158
+ );
159
+ expect(imageSrcs(first)[0]?.startsWith("blob:")).toBe(true);
160
+
161
+ // Content can move more than once — fullscreen hands it to compose, and a
162
+ // format switch then rebuilds compose's editor under it.
163
+ const second = createEditor();
164
+ second.commands.setContent(first.getJSON());
165
+ adoptPendingInlineImageUploads(second);
166
+
167
+ const third = createEditor();
168
+ third.commands.setContent(second.getJSON());
169
+ const adopted = adoptPendingInlineImageUploads(third);
170
+ expect(adopted).toHaveLength(1);
171
+ // Submitting from here still knows the image is on its way.
172
+ expect(hasPendingInlineImagePlaceholders(third.getJSON())).toBe(true);
173
+
174
+ finishUpload({ url: "https://cdn.test/shot.webp" });
175
+ await insert;
176
+ await Promise.all(adopted);
177
+
178
+ expect(imageSrcs(third)).toEqual(["https://cdn.test/shot.webp"]);
179
+ });
180
+ });
@@ -7,14 +7,23 @@
7
7
  */
8
8
 
9
9
  import { Extension, type Editor } from "@tiptap/core";
10
- import { Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
10
+ import {
11
+ AllSelection,
12
+ Plugin,
13
+ PluginKey,
14
+ Selection,
15
+ TextSelection,
16
+ } from "@tiptap/pm/state";
11
17
  import { liftTarget } from "@tiptap/pm/transform";
12
18
  import type { EditorView } from "@tiptap/pm/view";
13
19
  import { isLinkToolbarInputActive } from "./link-toolbar.js";
14
20
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
15
21
  import {
16
22
  getFixedFloatingContainerRect,
23
+ getFloatingArrowOffset,
17
24
  getFloatingPosition,
25
+ getRangeAnchorRect,
26
+ getVisibleClipRect,
18
27
  } from "./floating-position.js";
19
28
 
20
29
  const bubbleMenuKey = new PluginKey("bubbleMenu");
@@ -315,20 +324,22 @@ export const BubbleMenu = Extension.create({
315
324
  if (!el) return;
316
325
  el.style.display = "flex";
317
326
 
318
- // Position above selection center
327
+ // Anchor on the first visible line of the selection, centered on its
328
+ // horizontal span. Select-all lands on the text you are looking at
329
+ // rather than at the top of a document scrolled out of view.
319
330
  const { from, to } = view.state.selection;
320
- const start = view.coordsAtPos(from);
321
- const end = view.coordsAtPos(to);
322
331
  const dialog = view.dom.closest("dialog");
332
+ const containerRect = getFixedFloatingContainerRect(dialog);
333
+ const anchorRect = getRangeAnchorRect(
334
+ view,
335
+ from,
336
+ to,
337
+ getVisibleClipRect(view.dom, containerRect),
338
+ );
323
339
  const rect = el.getBoundingClientRect();
324
340
  const layout = getFloatingPosition({
325
- anchorRect: {
326
- left: start.left,
327
- right: end.right,
328
- top: Math.min(start.top, end.top),
329
- bottom: Math.max(start.bottom, end.bottom),
330
- },
331
- containerRect: getFixedFloatingContainerRect(dialog),
341
+ anchorRect,
342
+ containerRect,
332
343
  floatingWidth: rect.width,
333
344
  floatingHeight: rect.height,
334
345
  preferredPlacement: "top",
@@ -338,6 +349,16 @@ export const BubbleMenu = Extension.create({
338
349
 
339
350
  el.style.left = `${layout.left}px`;
340
351
  el.style.top = `${layout.top}px`;
352
+ el.dataset.placement = layout.placement;
353
+ el.style.setProperty(
354
+ "--floating-arrow-x",
355
+ `${getFloatingArrowOffset({
356
+ anchorRect,
357
+ containerRect,
358
+ floatingLeft: layout.left,
359
+ floatingWidth: rect.width,
360
+ })}px`,
361
+ );
341
362
 
342
363
  syncActive();
343
364
  }
@@ -358,13 +379,23 @@ export const BubbleMenu = Extension.create({
358
379
  function shouldShow(view: EditorView): boolean {
359
380
  const { state } = view;
360
381
  const { selection } = state;
361
- // Only show for non-empty text selections (not node selections)
362
- if (!(selection instanceof TextSelection) || selection.empty)
363
- return false;
364
- if (!selection.$from.parent.isTextblock) return false;
382
+ if (selection.empty) return false;
365
383
  // Hide when link input popup is open
366
384
  if (isLinkToolbarInputActive()) return false;
367
- return true;
385
+
386
+ // Select-all reports an AllSelection, not a TextSelection. Clearing
387
+ // formatting off a pasted draft and quoting a whole note are exactly
388
+ // what a document-wide selection is for, so it gets the same toolbar as
389
+ // dragging over everything — as long as there is text to format.
390
+ if (selection instanceof AllSelection) {
391
+ return (
392
+ state.doc.textBetween(selection.from, selection.to, " ").trim() !== ""
393
+ );
394
+ }
395
+
396
+ // Otherwise only non-empty text selections (not node selections)
397
+ if (!(selection instanceof TextSelection)) return false;
398
+ return selection.$from.parent.isTextblock;
368
399
  }
369
400
 
370
401
  return [
@@ -1,11 +1,13 @@
1
- interface FloatingAnchorRect {
1
+ import type { EditorView } from "@tiptap/pm/view";
2
+
3
+ export interface FloatingAnchorRect {
2
4
  left: number;
3
5
  right: number;
4
6
  top: number;
5
7
  bottom: number;
6
8
  }
7
9
 
8
- interface FloatingContainerRect {
10
+ export interface FloatingContainerRect {
9
11
  left: number;
10
12
  top: number;
11
13
  width: number;
@@ -165,3 +167,215 @@ export function getFloatingPosition(
165
167
  maxHeight,
166
168
  };
167
169
  }
170
+
171
+ /** Overflow values that clip a descendant out of view. */
172
+ const CLIPPING_OVERFLOW = new Set([
173
+ "auto",
174
+ "scroll",
175
+ "hidden",
176
+ "clip",
177
+ "overlay",
178
+ ]);
179
+
180
+ function intersectRects(
181
+ a: FloatingContainerRect,
182
+ b: FloatingContainerRect,
183
+ ): FloatingContainerRect {
184
+ const left = Math.max(a.left, b.left);
185
+ const top = Math.max(a.top, b.top);
186
+ const right = Math.min(a.left + a.width, b.left + b.width);
187
+ const bottom = Math.min(a.top + a.height, b.top + b.height);
188
+ return { left, top, width: right - left, height: bottom - top };
189
+ }
190
+
191
+ /**
192
+ * Narrows a floating container down to the region where the editor's content
193
+ * is actually on screen, by intersecting it with every clipping ancestor.
194
+ *
195
+ * The composer's editor sits inside its own scroll region, so a selection can
196
+ * run well past what the reader can see. Anchoring to the raw container would
197
+ * point the surface at text hidden behind the scroll edge; this keeps the
198
+ * anchor inside the scrollport.
199
+ *
200
+ * @param element - Editor element whose visible region is wanted
201
+ * @param containerRect - Bounds of the fixed-position floating container
202
+ * @returns Visible region in viewport coordinates
203
+ *
204
+ * @example
205
+ * const clip = getVisibleClipRect(view.dom, getFixedFloatingContainerRect(dialog));
206
+ */
207
+ export function getVisibleClipRect(
208
+ element: HTMLElement,
209
+ containerRect: FloatingContainerRect,
210
+ ): FloatingContainerRect {
211
+ let clip = containerRect;
212
+ let node = element.parentElement;
213
+
214
+ while (node && node !== document.body) {
215
+ const style = getComputedStyle(node);
216
+ if (
217
+ CLIPPING_OVERFLOW.has(style.overflowY) ||
218
+ CLIPPING_OVERFLOW.has(style.overflowX)
219
+ ) {
220
+ const rect = node.getBoundingClientRect();
221
+ const next = intersectRects(clip, {
222
+ left: rect.left,
223
+ top: rect.top,
224
+ width: rect.width,
225
+ height: rect.height,
226
+ });
227
+ // A zero-size intersection means the ancestor reported no layout at all
228
+ // (detached nodes, test environments). Keep the wider region instead of
229
+ // collapsing the anchor to nothing.
230
+ if (next.width > 0 && next.height > 0) clip = next;
231
+ }
232
+ node = node.parentElement;
233
+ }
234
+
235
+ return clip;
236
+ }
237
+
238
+ /**
239
+ * Reduces the line boxes of a selection to the rectangle a floating surface
240
+ * should point at.
241
+ *
242
+ * Horizontally this is the union of everything visible, so a selection that
243
+ * covers whole blocks centers on the text column rather than on wherever the
244
+ * first and last lines happen to break. Vertically it is the *first* visible
245
+ * line only: a selection running past the bottom of the screen still gets an
246
+ * anchor the reader can see, and flipping the surface below that line lands it
247
+ * inside the selection instead of somewhere off-screen.
248
+ *
249
+ * @param rects - Client rects of the selection, in viewport coordinates
250
+ * @param clipRect - Region the anchor must stay inside
251
+ * @returns Anchor rectangle, or null when nothing is visible
252
+ *
253
+ * @example
254
+ * const anchor = getVisibleAnchorRect(range.getClientRects(), clipRect);
255
+ */
256
+ export function getVisibleAnchorRect(
257
+ rects: readonly FloatingAnchorRect[],
258
+ clipRect: FloatingContainerRect,
259
+ ): FloatingAnchorRect | null {
260
+ const clipRight = clipRect.left + clipRect.width;
261
+ const clipBottom = clipRect.top + clipRect.height;
262
+
263
+ let left = Number.POSITIVE_INFINITY;
264
+ let right = Number.NEGATIVE_INFINITY;
265
+ let top = Number.POSITIVE_INFINITY;
266
+ let bottom = Number.POSITIVE_INFINITY;
267
+ let found = false;
268
+
269
+ for (const rect of rects) {
270
+ const rectTop = Math.max(rect.top, clipRect.top);
271
+ const rectBottom = Math.min(rect.bottom, clipBottom);
272
+ if (rectBottom <= rectTop) continue;
273
+ const rectLeft = Math.max(rect.left, clipRect.left);
274
+ const rectRight = Math.min(rect.right, clipRight);
275
+ if (rectRight < rectLeft) continue;
276
+
277
+ left = Math.min(left, rectLeft);
278
+ right = Math.max(right, rectRight);
279
+ top = Math.min(top, rectTop);
280
+ // Every kept rect ends below `top`, so the smallest bottom belongs to the
281
+ // shortest box starting there — the first line, even when the browser also
282
+ // reports a full-height rect for an enclosing block.
283
+ bottom = Math.min(bottom, rectBottom);
284
+ found = true;
285
+ }
286
+
287
+ return found ? { left, right, top, bottom } : null;
288
+ }
289
+
290
+ function measureRangeRects(
291
+ view: EditorView,
292
+ from: number,
293
+ to: number,
294
+ ): FloatingAnchorRect[] {
295
+ if (typeof document === "undefined" || !document.createRange) return [];
296
+
297
+ try {
298
+ const start = view.domAtPos(from);
299
+ const end = view.domAtPos(to);
300
+ const range = document.createRange();
301
+ range.setStart(start.node, start.offset);
302
+ range.setEnd(end.node, end.offset);
303
+ const rects = range.getClientRects?.();
304
+ return rects ? Array.from(rects) : [];
305
+ } catch {
306
+ // Positions that do not resolve to a DOM range (node selections, widgets)
307
+ // fall back to the caller's endpoint coordinates.
308
+ return [];
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Resolves the rectangle a floating surface should point at for a document
314
+ * range.
315
+ *
316
+ * Measuring the caret at each end is only correct while the range stays on one
317
+ * line: as soon as it wraps, the "anchor" mixes the first line's left edge with
318
+ * the last line's right edge and centers on a point inside neither. This
319
+ * measures the range's real line boxes instead.
320
+ *
321
+ * @param view - Editor view owning the range
322
+ * @param from - Range start position
323
+ * @param to - Range end position
324
+ * @param clipRect - Region the anchor must stay inside
325
+ * @returns Anchor rectangle in viewport coordinates
326
+ *
327
+ * @example
328
+ * const anchor = getRangeAnchorRect(view, from, to, clipRect);
329
+ */
330
+ export function getRangeAnchorRect(
331
+ view: EditorView,
332
+ from: number,
333
+ to: number,
334
+ clipRect: FloatingContainerRect,
335
+ ): FloatingAnchorRect {
336
+ const measured = getVisibleAnchorRect(
337
+ measureRangeRects(view, from, to),
338
+ clipRect,
339
+ );
340
+ if (measured) return measured;
341
+
342
+ const start = view.coordsAtPos(from);
343
+ const end = view.coordsAtPos(to);
344
+ const endpoints: FloatingAnchorRect = {
345
+ left: Math.min(start.left, end.left),
346
+ right: Math.max(start.right, end.right),
347
+ top: start.top,
348
+ bottom: start.bottom,
349
+ };
350
+ return getVisibleAnchorRect([endpoints], clipRect) ?? endpoints;
351
+ }
352
+
353
+ /**
354
+ * Horizontal offset of a floating surface's pointer arrow, measured from the
355
+ * surface's own left edge.
356
+ *
357
+ * The surface is clamped to the container, so a centered arrow drifts off the
358
+ * anchor near the edges of the screen. This keeps it on the anchor without
359
+ * letting it slide past the rounded corners.
360
+ *
361
+ * @param options - Anchor, container, and the surface's placed geometry
362
+ * @returns Offset in pixels from the surface's left edge
363
+ *
364
+ * @example
365
+ * el.style.setProperty("--floating-arrow-x", `${getFloatingArrowOffset({...})}px`);
366
+ */
367
+ export function getFloatingArrowOffset(options: {
368
+ anchorRect: FloatingAnchorRect;
369
+ containerRect: FloatingContainerRect;
370
+ floatingLeft: number;
371
+ floatingWidth: number;
372
+ inset?: number;
373
+ }): number {
374
+ const inset = options.inset ?? 10;
375
+ const anchorCenter =
376
+ (options.anchorRect.left + options.anchorRect.right) / 2 -
377
+ options.containerRect.left;
378
+ const min = Math.min(inset, options.floatingWidth / 2);
379
+ const max = Math.max(options.floatingWidth - inset, min);
380
+ return clamp(anchorCenter - options.floatingLeft, min, max);
381
+ }
@@ -204,7 +204,9 @@ export async function rehostInlineImage(
204
204
  * Scans the editor's document for image srcs that match pending registry entries
205
205
  * (`blob:` placeholders from the insert flow, or remote/`data:` placeholders
206
206
  * from the paste-rehost flow). For each match, takes ownership from the original
207
- * editor and sets up replacement/removal watchers on the new editor.
207
+ * editor and sets up replacement/removal watchers on the new editor. Content can
208
+ * pass through several editors before it is submitted, so an upload adopted once
209
+ * can be adopted again.
208
210
  *
209
211
  * Call this immediately after `setContent` with JSON that may contain pending
210
212
  * placeholders (e.g. after fullscreen close transfers content back to compose).
@@ -223,12 +225,14 @@ export function adoptPendingInlineImageUploads(
223
225
  const src = node.attrs.src as string;
224
226
  if (typeof src !== "string") return;
225
227
 
226
- const uploaded = inflightUploads.get(src);
228
+ const uploaded = inflightUploads.get(src) ?? adoptedUploads.get(src);
227
229
  if (!uploaded) return;
228
230
 
229
231
  // Take ownership — prevents original editor's finally from revoking the URL.
230
232
  // Move to adoptedUploads so resolveInlineImageUrls can still find the promise
231
- // if the user submits before the upload completes.
233
+ // if the user submits before the upload completes. Content can be handed on
234
+ // more than once (fullscreen → compose → a format switch), so an entry
235
+ // already sitting in adoptedUploads is adopted again rather than skipped.
232
236
  inflightUploads.delete(src);
233
237
  adoptedUploads.set(src, uploaded);
234
238