@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -0,0 +1,263 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Editor, type JSONContent } from "@tiptap/core";
5
+ import { createMarkdownContentExtensions } from "../../../lib/markdown-manager.js";
6
+ import { TableControls } from "../table-controls.js";
7
+ import { DEFAULT_TABLE_CONTROL_LABELS } from "../table-control-labels.js";
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ function requireElement<T extends globalThis.Element>(
12
+ element: T | null,
13
+ message: string,
14
+ ): T {
15
+ if (!element) throw new Error(message);
16
+ return element;
17
+ }
18
+
19
+ function createEditor(): Editor {
20
+ const element = document.createElement("div");
21
+ document.body.appendChild(element);
22
+ const editor = new Editor({
23
+ element,
24
+ extensions: [
25
+ ...createMarkdownContentExtensions(),
26
+ TableControls.configure({ labels: DEFAULT_TABLE_CONTROL_LABELS }),
27
+ ],
28
+ content: "<p></p>",
29
+ });
30
+ vi.spyOn(editor.view, "coordsAtPos").mockImplementation(() => ({
31
+ left: 80,
32
+ right: 100,
33
+ top: 120,
34
+ bottom: 140,
35
+ }));
36
+ editors.push(editor);
37
+ return editor;
38
+ }
39
+
40
+ function tableJson(editor: Editor): JSONContent {
41
+ const table = editor.getJSON().content?.find((node) => node.type === "table");
42
+ if (!table) throw new Error("expected table JSON");
43
+ return table;
44
+ }
45
+
46
+ function tableDimensions(editor: Editor): { rows: number; columns: number } {
47
+ const table = tableJson(editor);
48
+ const rows = table.content ?? [];
49
+ return {
50
+ rows: rows.length,
51
+ columns: rows[0]?.content?.length ?? 0,
52
+ };
53
+ }
54
+
55
+ function cursorPositionInRow(editor: Editor, targetRow: number): number {
56
+ let row = -1;
57
+ let position: number | null = null;
58
+ editor.state.doc.descendants((node, pos) => {
59
+ if (node.type.name === "tableRow") row += 1;
60
+ if (
61
+ position === null &&
62
+ row === targetRow &&
63
+ (node.type.name === "tableCell" || node.type.name === "tableHeader")
64
+ ) {
65
+ position = pos + 2;
66
+ return false;
67
+ }
68
+ return position === null;
69
+ });
70
+ if (position === null) throw new Error(`expected row ${targetRow}`);
71
+ return position;
72
+ }
73
+
74
+ async function focusTable(editor: Editor, rows = 3, columns = 3) {
75
+ editor.commands.focus();
76
+ editor.commands.insertTable({
77
+ rows,
78
+ cols: columns,
79
+ withHeaderRow: true,
80
+ });
81
+ await vi.waitFor(() => {
82
+ expect(
83
+ document.querySelector<HTMLElement>(".tiptap-table-controls")?.style
84
+ .display,
85
+ ).toBe("flex");
86
+ });
87
+ }
88
+
89
+ function clickButton(label: string): HTMLButtonElement {
90
+ const button = requireElement(
91
+ document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`),
92
+ `expected ${label} button`,
93
+ );
94
+ button.dispatchEvent(
95
+ new globalThis.MouseEvent("click", {
96
+ bubbles: true,
97
+ cancelable: true,
98
+ detail: 1,
99
+ }),
100
+ );
101
+ return button;
102
+ }
103
+
104
+ afterEach(() => {
105
+ vi.restoreAllMocks();
106
+ while (editors.length > 0) editors.pop()?.destroy();
107
+ document.body.innerHTML = "";
108
+ });
109
+
110
+ describe("TableControls", () => {
111
+ it("appears only while the focused selection is inside a table", async () => {
112
+ const editor = createEditor();
113
+ const toolbar = requireElement(
114
+ document.querySelector<HTMLElement>(".tiptap-table-controls"),
115
+ "expected table toolbar",
116
+ );
117
+ expect(toolbar.style.display).toBe("none");
118
+
119
+ await focusTable(editor);
120
+ expect(toolbar.style.display).toBe("flex");
121
+
122
+ editor.commands.setTextSelection(editor.state.doc.content.size - 1);
123
+ await vi.waitFor(() => expect(toolbar.style.display).toBe("none"));
124
+ });
125
+
126
+ it("adds rows and columns without losing the active table selection", async () => {
127
+ const editor = createEditor();
128
+ await focusTable(editor);
129
+ const selectionBefore = editor.state.selection.from;
130
+ const addColumn = requireElement(
131
+ document.querySelector<HTMLButtonElement>(
132
+ 'button[aria-label="Add column after"]',
133
+ ),
134
+ "expected add-column button",
135
+ );
136
+ const pointerDown = new globalThis.MouseEvent("mousedown", {
137
+ bubbles: true,
138
+ cancelable: true,
139
+ });
140
+ addColumn.dispatchEvent(pointerDown);
141
+ expect(pointerDown.defaultPrevented).toBe(true);
142
+ expect(editor.state.selection.from).toBe(selectionBefore);
143
+
144
+ addColumn.dispatchEvent(
145
+ new globalThis.MouseEvent("click", { bubbles: true, detail: 1 }),
146
+ );
147
+ expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 4 });
148
+ clickButton("Add row below");
149
+ expect(tableDimensions(editor)).toEqual({ rows: 4, columns: 4 });
150
+ });
151
+
152
+ it("offers complete structural actions and protects the last row and column", async () => {
153
+ const editor = createEditor();
154
+ await focusTable(editor, 2, 2);
155
+
156
+ clickButton("Table options");
157
+ expect(
158
+ document.querySelector<HTMLElement>(".tiptap-table-options")?.hidden,
159
+ ).toBe(false);
160
+ clickButton("Add row above");
161
+ expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 2 });
162
+
163
+ clickButton("Table options");
164
+ clickButton("Delete row");
165
+ clickButton("Table options");
166
+ clickButton("Delete row");
167
+ expect(tableDimensions(editor)).toEqual({ rows: 1, columns: 2 });
168
+
169
+ editor.commands.setTextSelection(cursorPositionInRow(editor, 0));
170
+ clickButton("Table options");
171
+ expect(
172
+ document.querySelector<HTMLButtonElement>(
173
+ 'button[aria-label="Delete row"]',
174
+ )?.disabled,
175
+ ).toBe(true);
176
+ expect(
177
+ document.querySelector<HTMLButtonElement>(
178
+ 'button[aria-label="Delete column"]',
179
+ )?.disabled,
180
+ ).toBe(false);
181
+ clickButton("Delete column");
182
+ expect(tableDimensions(editor)).toEqual({ rows: 1, columns: 1 });
183
+
184
+ clickButton("Table options");
185
+ expect(
186
+ document.querySelector<HTMLButtonElement>(
187
+ 'button[aria-label="Delete column"]',
188
+ )?.disabled,
189
+ ).toBe(true);
190
+ });
191
+
192
+ it("toggles the first header row even when the caret is in a later row", async () => {
193
+ const editor = createEditor();
194
+ await focusTable(editor, 3, 2);
195
+ editor.commands.setTextSelection(cursorPositionInRow(editor, 1));
196
+
197
+ clickButton("Table options");
198
+ clickButton("Toggle header row");
199
+ let rows = tableJson(editor).content ?? [];
200
+ expect(rows[0]?.content?.map((cell) => cell.type)).toEqual([
201
+ "tableCell",
202
+ "tableCell",
203
+ ]);
204
+ expect(rows[1]?.content?.[0]?.type).toBe("tableCell");
205
+
206
+ clickButton("Table options");
207
+ clickButton("Toggle header row");
208
+ rows = tableJson(editor).content ?? [];
209
+ expect(rows[0]?.content?.map((cell) => cell.type)).toEqual([
210
+ "tableHeader",
211
+ "tableHeader",
212
+ ]);
213
+ });
214
+
215
+ it("supports keyboard entry and Escape focus restoration", async () => {
216
+ const editor = createEditor();
217
+ await focusTable(editor);
218
+ const shortcut = new globalThis.KeyboardEvent("keydown", {
219
+ key: "F10",
220
+ altKey: true,
221
+ bubbles: true,
222
+ cancelable: true,
223
+ });
224
+ editor.view.dom.dispatchEvent(shortcut);
225
+ expect(shortcut.defaultPrevented).toBe(true);
226
+ expect(document.activeElement?.getAttribute("aria-label")).toBe(
227
+ "Add row below",
228
+ );
229
+
230
+ document.activeElement?.dispatchEvent(
231
+ new globalThis.KeyboardEvent("keydown", {
232
+ key: "Escape",
233
+ bubbles: true,
234
+ cancelable: true,
235
+ }),
236
+ );
237
+ expect(editor.view.hasFocus()).toBe(true);
238
+ });
239
+
240
+ it("dismisses options outside the editor and removes floating DOM on destroy", async () => {
241
+ const editor = createEditor();
242
+ await focusTable(editor);
243
+ clickButton("Table options");
244
+ requireElement(
245
+ editor.view.dom.querySelector<HTMLElement>("td, th"),
246
+ "expected table cell",
247
+ ).dispatchEvent(new globalThis.MouseEvent("mousedown", { bubbles: true }));
248
+ expect(
249
+ document.querySelector<HTMLElement>(".tiptap-table-options")?.hidden,
250
+ ).toBe(true);
251
+
252
+ clickButton("Table options");
253
+ document.body.dispatchEvent(
254
+ new globalThis.MouseEvent("mousedown", { bubbles: true }),
255
+ );
256
+ expect(
257
+ document.querySelector<HTMLElement>(".tiptap-table-options")?.hidden,
258
+ ).toBe(true);
259
+
260
+ editor.destroy();
261
+ expect(document.querySelector(".tiptap-table-controls")).toBeNull();
262
+ });
263
+ });
@@ -0,0 +1,179 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Editor, type JSONContent } from "@tiptap/core";
5
+ import { createEditorExtensions } from "../extensions.js";
6
+ import { getSlashCommands } from "../slash-commands.js";
7
+ import { DEFAULT_TABLE_CONTROL_LABELS } from "../table-control-labels.js";
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ function requireElement<T extends globalThis.Element>(
12
+ element: T | null,
13
+ message: string,
14
+ ): T {
15
+ if (!element) throw new Error(message);
16
+ return element;
17
+ }
18
+
19
+ function createEditor(): Editor {
20
+ const element = document.createElement("div");
21
+ document.body.appendChild(element);
22
+ const editor = new Editor({
23
+ element,
24
+ extensions: createEditorExtensions({
25
+ toolbarMode: "compose",
26
+ tableControlLabels: DEFAULT_TABLE_CONTROL_LABELS,
27
+ }),
28
+ content: "<p>/table</p>",
29
+ });
30
+ vi.spyOn(editor.view, "coordsAtPos").mockImplementation(() => ({
31
+ left: 80,
32
+ right: 100,
33
+ top: 120,
34
+ bottom: 140,
35
+ }));
36
+ editor.commands.setTextSelection(7);
37
+ editors.push(editor);
38
+ return editor;
39
+ }
40
+
41
+ function openPicker(editor: Editor): HTMLElement {
42
+ const tableCommand = getSlashCommands(editor).find(
43
+ (item) => item.label === "Table",
44
+ );
45
+ if (!tableCommand) throw new Error("expected Table slash command");
46
+ tableCommand.command(editor, { from: 1, to: 7 });
47
+ return requireElement(
48
+ document.querySelector<HTMLElement>(".tiptap-table-size-picker"),
49
+ "expected table size picker",
50
+ );
51
+ }
52
+
53
+ function tableJson(editor: Editor): JSONContent | undefined {
54
+ return editor.getJSON().content?.find((node) => node.type === "table");
55
+ }
56
+
57
+ function tableDimensions(editor: Editor): { rows: number; columns: number } {
58
+ const rows = tableJson(editor)?.content ?? [];
59
+ return { rows: rows.length, columns: rows[0]?.content?.length ?? 0 };
60
+ }
61
+
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ while (editors.length > 0) editors.pop()?.destroy();
65
+ document.body.innerHTML = "";
66
+ });
67
+
68
+ describe("table size picker", () => {
69
+ it("opens at 3 by 3 without inserting a table immediately", () => {
70
+ const editor = createEditor();
71
+ const picker = openPicker(editor);
72
+
73
+ expect(editor.getText()).toBe("");
74
+ expect(tableJson(editor)).toBeUndefined();
75
+ expect(picker.getAttribute("aria-label")).toContain("Insert 3 by 3 table");
76
+ expect(
77
+ picker.querySelectorAll(".tiptap-table-size-cell.is-selected"),
78
+ ).toHaveLength(9);
79
+ expect(
80
+ picker.querySelector<HTMLButtonElement>(
81
+ '.tiptap-table-size-cell[data-row="3"][data-column="3"]',
82
+ )?.tabIndex,
83
+ ).toBe(0);
84
+ });
85
+
86
+ it("inserts the pointer-selected dimensions with a header row", () => {
87
+ const editor = createEditor();
88
+ const picker = openPicker(editor);
89
+ const target = requireElement(
90
+ picker.querySelector<HTMLButtonElement>(
91
+ '.tiptap-table-size-cell[data-row="4"][data-column="5"]',
92
+ ),
93
+ "expected 4 by 5 option",
94
+ );
95
+ target.dispatchEvent(
96
+ new globalThis.PointerEvent("pointerenter", { bubbles: true }),
97
+ );
98
+ target.click();
99
+
100
+ expect(tableDimensions(editor)).toEqual({ rows: 4, columns: 5 });
101
+ expect(tableJson(editor)?.content?.[0]?.content?.[0]?.type).toBe(
102
+ "tableHeader",
103
+ );
104
+ expect(document.querySelector(".tiptap-table-size-picker")).toBeNull();
105
+ });
106
+
107
+ it("supports bounded arrow navigation and Enter insertion", async () => {
108
+ const editor = createEditor();
109
+ const picker = openPicker(editor);
110
+ await vi.waitFor(() => {
111
+ expect(document.activeElement?.getAttribute("data-row")).toBe("3");
112
+ });
113
+ const current = () =>
114
+ requireElement(
115
+ picker.querySelector<HTMLButtonElement>(
116
+ ".tiptap-table-size-cell.is-current",
117
+ ),
118
+ "expected current size",
119
+ );
120
+
121
+ current().dispatchEvent(
122
+ new globalThis.KeyboardEvent("keydown", {
123
+ key: "ArrowRight",
124
+ bubbles: true,
125
+ }),
126
+ );
127
+ current().dispatchEvent(
128
+ new globalThis.KeyboardEvent("keydown", {
129
+ key: "ArrowDown",
130
+ bubbles: true,
131
+ }),
132
+ );
133
+ current().dispatchEvent(
134
+ new globalThis.KeyboardEvent("keydown", {
135
+ key: "Enter",
136
+ bubbles: true,
137
+ }),
138
+ );
139
+
140
+ expect(tableDimensions(editor)).toEqual({ rows: 4, columns: 4 });
141
+ });
142
+
143
+ it("cancels on Escape and restores editor focus", async () => {
144
+ const editor = createEditor();
145
+ openPicker(editor);
146
+ await vi.waitFor(() => {
147
+ expect(document.activeElement?.getAttribute("data-row")).toBe("3");
148
+ });
149
+ document.activeElement?.dispatchEvent(
150
+ new globalThis.KeyboardEvent("keydown", {
151
+ key: "Escape",
152
+ bubbles: true,
153
+ cancelable: true,
154
+ }),
155
+ );
156
+
157
+ expect(document.querySelector(".tiptap-table-size-picker")).toBeNull();
158
+ expect(tableJson(editor)).toBeUndefined();
159
+ expect(editor.view.hasFocus()).toBe(true);
160
+ });
161
+
162
+ it("cancels on outside click and cleans up when the editor is destroyed", async () => {
163
+ const editor = createEditor();
164
+ openPicker(editor);
165
+ await vi.waitFor(() => {
166
+ expect(document.activeElement?.getAttribute("data-row")).toBe("3");
167
+ });
168
+ document.body.dispatchEvent(
169
+ new globalThis.MouseEvent("mousedown", { bubbles: true }),
170
+ );
171
+ expect(document.querySelector(".tiptap-table-size-picker")).toBeNull();
172
+
173
+ editor.commands.setContent("<p>/table</p>");
174
+ editor.commands.setTextSelection(7);
175
+ openPicker(editor);
176
+ editor.destroy();
177
+ expect(document.querySelector(".tiptap-table-size-picker")).toBeNull();
178
+ });
179
+ });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { Extension, type Editor } from "@tiptap/core";
10
10
  import { Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
11
+ import { liftTarget } from "@tiptap/pm/transform";
11
12
  import type { EditorView } from "@tiptap/pm/view";
12
13
  import { isLinkToolbarInputActive } from "./link-toolbar.js";
13
14
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
@@ -70,6 +71,92 @@ export function toggleMarkAndExit(editor: Editor, markName: string): void {
70
71
  .run();
71
72
  }
72
73
 
74
+ /**
75
+ * Clears presentational formatting while preserving semantic content structure.
76
+ *
77
+ * Clearable marks (bold, italic, and similar text styling) are removed while
78
+ * marks configured with `clearable: false`, such as links, are retained.
79
+ * Headings become paragraphs and selected blockquote content is lifted out of
80
+ * its quote wrapper. Lists, code blocks, tables, media, and other structural
81
+ * nodes remain intact.
82
+ *
83
+ * @param editor - Editor whose current selection should be cleared
84
+ * @returns Nothing
85
+ */
86
+ export function clearFormatting(editor: Editor): void {
87
+ const { doc, selection } = editor.state;
88
+ const { from, to } = selection;
89
+ const headingPositions: number[] = [];
90
+ const blockquoteRanges: Array<{ from: number; to: number }> = [];
91
+
92
+ doc.nodesBetween(from, to, (node, pos) => {
93
+ if (node.type === editor.schema.nodes.heading) {
94
+ headingPositions.push(pos);
95
+ }
96
+
97
+ if (node.type === editor.schema.nodes.blockquote) {
98
+ blockquoteRanges.push({
99
+ from: Math.max(from, pos + 1),
100
+ to: Math.min(to, pos + node.nodeSize - 1),
101
+ });
102
+ }
103
+ });
104
+
105
+ editor
106
+ .chain()
107
+ .focus()
108
+ .unsetAllMarks()
109
+ .command(({ tr }) => {
110
+ const headingType = editor.schema.nodes.heading;
111
+ const paragraphType = editor.schema.nodes.paragraph;
112
+
113
+ if (headingType && paragraphType) {
114
+ for (const position of headingPositions) {
115
+ const mappedPosition = tr.mapping.map(position);
116
+ const node = tr.doc.nodeAt(mappedPosition);
117
+ if (node?.type !== headingType) continue;
118
+
119
+ const $position = tr.doc.resolve(mappedPosition);
120
+ const index = $position.index();
121
+ if (
122
+ $position.parent.canReplaceWith(index, index + 1, paragraphType)
123
+ ) {
124
+ tr.setNodeMarkup(mappedPosition, paragraphType);
125
+ }
126
+ }
127
+ }
128
+
129
+ const blockquoteType = editor.schema.nodes.blockquote;
130
+ if (blockquoteType) {
131
+ // Nested wrappers must be lifted from the inside out. Mapping the
132
+ // original ranges after each lift keeps later positions accurate.
133
+ for (let index = blockquoteRanges.length - 1; index >= 0; index -= 1) {
134
+ const range = blockquoteRanges[index];
135
+ if (!range) continue;
136
+ const mappedFrom = tr.mapping.map(range.from, 1);
137
+ const mappedTo = tr.mapping.map(range.to, -1);
138
+ if (mappedFrom > mappedTo) continue;
139
+
140
+ const nodeRange = tr.doc
141
+ .resolve(mappedFrom)
142
+ .blockRange(
143
+ tr.doc.resolve(mappedTo),
144
+ (node) => node.type === blockquoteType,
145
+ );
146
+ if (!nodeRange) continue;
147
+
148
+ const target = liftTarget(nodeRange);
149
+ if (target !== null) tr.lift(nodeRange, target);
150
+ }
151
+ }
152
+
153
+ const mappedEnd = tr.mapping.map(to, -1);
154
+ tr.setSelection(TextSelection.create(tr.doc, mappedEnd));
155
+ return true;
156
+ })
157
+ .run();
158
+ }
159
+
73
160
  function getButtons(
74
161
  editor: Editor,
75
162
  toolbarMode: FormattingToolbarMode,
@@ -114,16 +201,7 @@ function getButtons(
114
201
  key: "clear",
115
202
  icon: ICONS.clear,
116
203
  title: "Clear formatting",
117
- action: () => {
118
- const { to } = editor.state.selection;
119
- editor
120
- .chain()
121
- .focus()
122
- .unsetAllMarks()
123
- .clearNodes()
124
- .setTextSelection(to)
125
- .run();
126
- },
204
+ action: () => clearFormatting(editor),
127
205
  isActive: () => false,
128
206
  },
129
207
  ];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Continuous Lists
3
+ *
4
+ * Rich-text sources can paste one visual list as several adjacent list nodes.
5
+ * Adjacent wrappers of the same type have no visible or semantic separator, so
6
+ * normalize them into one list. A paragraph, a different list type, or any
7
+ * other block remains an intentional boundary.
8
+ */
9
+
10
+ import { Extension } from "@tiptap/core";
11
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
12
+ import { Plugin } from "@tiptap/pm/state";
13
+ import type { EditorState, Transaction } from "@tiptap/pm/state";
14
+ import { canJoin } from "@tiptap/pm/transform";
15
+
16
+ function isStandardList(node: ProseMirrorNode): boolean {
17
+ return node.type.name === "bulletList" || node.type.name === "orderedList";
18
+ }
19
+
20
+ function findJoinableListBoundary(
21
+ node: ProseMirrorNode,
22
+ nodePosition = -1,
23
+ document = node,
24
+ ): number | null {
25
+ const contentStart = nodePosition + 1;
26
+ let childOffset = 0;
27
+ let previousChild: ProseMirrorNode | null = null;
28
+
29
+ for (let index = 0; index < node.childCount; index += 1) {
30
+ const child = node.child(index);
31
+ const childPosition = contentStart + childOffset;
32
+
33
+ if (
34
+ previousChild &&
35
+ isStandardList(previousChild) &&
36
+ previousChild.type === child.type &&
37
+ canJoin(document, childPosition)
38
+ ) {
39
+ return childPosition;
40
+ }
41
+
42
+ const nestedBoundary = findJoinableListBoundary(
43
+ child,
44
+ childPosition,
45
+ document,
46
+ );
47
+ if (nestedBoundary !== null) return nestedBoundary;
48
+
49
+ childOffset += child.nodeSize;
50
+ previousChild = child;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Builds a transaction that joins every adjacent same-type list fragment.
58
+ *
59
+ * @param state - Current editor state
60
+ * @returns A joining transaction, or null when the document is normalized
61
+ * @example
62
+ * const tr = buildContinuousListsTransaction(editor.state);
63
+ * if (tr) editor.view.dispatch(tr);
64
+ */
65
+ export function buildContinuousListsTransaction(
66
+ state: EditorState,
67
+ ): Transaction | null {
68
+ const tr = state.tr;
69
+ let changed = false;
70
+ let boundary = findJoinableListBoundary(tr.doc);
71
+
72
+ while (boundary !== null) {
73
+ tr.join(boundary);
74
+ changed = true;
75
+ boundary = findJoinableListBoundary(tr.doc);
76
+ }
77
+
78
+ return changed ? tr : null;
79
+ }
80
+
81
+ export const ContinuousLists = Extension.create({
82
+ name: "continuousLists",
83
+
84
+ onCreate() {
85
+ const tr = buildContinuousListsTransaction(this.editor.state);
86
+ if (tr) this.editor.view.dispatch(tr.setMeta("addToHistory", false));
87
+ },
88
+
89
+ addProseMirrorPlugins() {
90
+ return [
91
+ new Plugin({
92
+ appendTransaction: (transactions, _oldState, newState) => {
93
+ if (!transactions.some((transaction) => transaction.docChanged)) {
94
+ return null;
95
+ }
96
+
97
+ return buildContinuousListsTransaction(newState);
98
+ },
99
+ }),
100
+ ];
101
+ },
102
+ });
@@ -13,6 +13,7 @@ import type { FormattingToolbarMode } from "./toolbar-mode.js";
13
13
  import type { PasteMediaOptions } from "./paste-media.js";
14
14
  import type { RehostImagesOptions } from "./rehost-images.js";
15
15
  import type { ImageNodeLabels } from "./image-node.js";
16
+ import type { TableControlLabels } from "./table-control-labels.js";
16
17
  import { normalizeFootnoteArtifacts } from "../../lib/footnotes.js";
17
18
  import { tiptapJsonToMarkdown } from "../../lib/tiptap-to-markdown.js";
18
19
  import { parseMarkdownDocument } from "../../lib/markdown-manager.js";
@@ -29,6 +30,7 @@ export interface CreateEditorOptions {
29
30
  pasteMedia?: PasteMediaOptions;
30
31
  rehostImages?: RehostImagesOptions;
31
32
  imageNodeLabels?: Partial<ImageNodeLabels>;
33
+ tableControlLabels?: TableControlLabels;
32
34
  }
33
35
 
34
36
  /**
@@ -46,6 +48,7 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
46
48
  pasteMedia: options.pasteMedia,
47
49
  rehostImages: options.rehostImages,
48
50
  imageNodeLabels: options.imageNodeLabels,
51
+ tableControlLabels: options.tableControlLabels,
49
52
  }),
50
53
  content: options.content ?? undefined,
51
54
  editorProps: {