@jant/core 0.3.36 → 0.3.38

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Thread Context Interactions
3
+ *
4
+ * 1. Expand/collapse faded ancestor context via toggle button
5
+ * 2. Auto-scroll to current post on thread detail pages
6
+ */
7
+
8
+ function parsePixelValue(value: string, fallback: number): number {
9
+ const parsed = Number.parseFloat(value);
10
+ return Number.isFinite(parsed) ? parsed : fallback;
11
+ }
12
+
13
+ function getCollapsedMaxHeight(container: HTMLElement): number {
14
+ const value = getComputedStyle(container).getPropertyValue(
15
+ "--site-thread-context-max-height",
16
+ );
17
+ return parsePixelValue(value, 188);
18
+ }
19
+
20
+ function getPendingImages(container: HTMLElement): HTMLImageElement[] {
21
+ return Array.from(container.querySelectorAll("img")).filter(
22
+ (image) => !image.complete,
23
+ );
24
+ }
25
+
26
+ function waitForContentToSettle(
27
+ container: HTMLElement,
28
+ callback: () => void,
29
+ ): void {
30
+ const pendingImages = getPendingImages(container);
31
+ if (pendingImages.length === 0) {
32
+ requestAnimationFrame(() => {
33
+ requestAnimationFrame(callback);
34
+ });
35
+ return;
36
+ }
37
+
38
+ let remaining = pendingImages.length;
39
+ const handleDone = (): void => {
40
+ remaining -= 1;
41
+ if (remaining === 0) {
42
+ callback();
43
+ }
44
+ };
45
+
46
+ pendingImages.forEach((image) => {
47
+ image.addEventListener("load", handleDone, { once: true });
48
+ image.addEventListener("error", handleDone, { once: true });
49
+ });
50
+ }
51
+
52
+ function updateThreadContextState(
53
+ container: HTMLElement,
54
+ toggle: HTMLElement,
55
+ allowExpand: boolean,
56
+ ): void {
57
+ const collapsedMaxHeight = getCollapsedMaxHeight(container);
58
+ const isExpanded = container.classList.contains("expanded");
59
+ const overflows = container.scrollHeight > collapsedMaxHeight + 1;
60
+ const showMoreLabel = toggle.dataset.labelMore ?? "Show more";
61
+ const showLessLabel = toggle.dataset.labelLess ?? "Show less";
62
+
63
+ if (!overflows) {
64
+ if (allowExpand) {
65
+ container.classList.remove(
66
+ "thread-context-collapsed",
67
+ "thread-context-faded",
68
+ "expanded",
69
+ );
70
+ } else {
71
+ container.classList.add("thread-context-collapsed");
72
+ container.classList.remove("thread-context-faded", "expanded");
73
+ }
74
+ toggle.classList.add("hidden");
75
+ toggle.textContent = showMoreLabel;
76
+ toggle.setAttribute("aria-expanded", "false");
77
+ return;
78
+ }
79
+
80
+ container.classList.add("thread-context-collapsed");
81
+ container.classList.add("thread-context-faded");
82
+ toggle.classList.remove("hidden");
83
+ toggle.textContent = isExpanded ? showLessLabel : showMoreLabel;
84
+ toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
85
+ }
86
+
87
+ function setupThreadContext(group: HTMLElement): void {
88
+ const container = group.querySelector<HTMLElement>("[data-thread-context]");
89
+ const toggle = group.querySelector<HTMLElement>(
90
+ "[data-thread-context-toggle]",
91
+ );
92
+ if (!container || !toggle) return;
93
+
94
+ let allowExpand = false;
95
+ updateThreadContextState(container, toggle, allowExpand);
96
+
97
+ waitForContentToSettle(container, () => {
98
+ allowExpand = true;
99
+ updateThreadContextState(container, toggle, allowExpand);
100
+ });
101
+
102
+ if ("ResizeObserver" in globalThis) {
103
+ const observer = new globalThis.ResizeObserver(() => {
104
+ updateThreadContextState(container, toggle, allowExpand);
105
+ });
106
+ observer.observe(container);
107
+ }
108
+ }
109
+
110
+ // Expand/collapse: event delegation on toggle buttons
111
+ document.addEventListener("click", (e) => {
112
+ const toggle = (e.target as HTMLElement).closest<HTMLElement>(
113
+ "[data-thread-context-toggle]",
114
+ );
115
+ if (!toggle) return;
116
+
117
+ const container = toggle
118
+ .closest(".thread-group")
119
+ ?.querySelector<HTMLElement>("[data-thread-context]");
120
+ if (!container) return;
121
+
122
+ container.classList.toggle("expanded");
123
+ updateThreadContextState(container, toggle, true);
124
+ });
125
+
126
+ // Auto-scroll to current post on detail pages
127
+ document.addEventListener("DOMContentLoaded", () => {
128
+ document.querySelectorAll(".thread-group").forEach((group) => {
129
+ if (group instanceof HTMLElement) {
130
+ setupThreadContext(group);
131
+ }
132
+ });
133
+
134
+ const current = document.querySelector("[data-post-current]");
135
+ if (!current) return;
136
+
137
+ requestAnimationFrame(() => {
138
+ current.scrollIntoView({ behavior: "smooth", block: "center" });
139
+ });
140
+ });
@@ -5,7 +5,17 @@
5
5
  */
6
6
 
7
7
  import { Editor, type JSONContent } from "@tiptap/core";
8
+ import StarterKit from "@tiptap/starter-kit";
9
+ import { Markdown } from "tiptap-markdown";
10
+ import {
11
+ Table,
12
+ TableRow,
13
+ TableCell,
14
+ TableHeader,
15
+ } from "@tiptap/extension-table";
8
16
  import { createEditorExtensions } from "./extensions.js";
17
+ import { ImageNode } from "./image-node.js";
18
+ import { MoreBreak } from "./more-break.js";
9
19
 
10
20
  export interface CreateEditorOptions {
11
21
  element: HTMLElement;
@@ -28,6 +38,10 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
28
38
  placeholder: options.placeholder,
29
39
  }),
30
40
  content: options.content ?? undefined,
41
+ editorProps: {
42
+ scrollMargin: { top: 5, right: 5, bottom: 80, left: 5 },
43
+ scrollThreshold: { top: 5, right: 5, bottom: 80, left: 5 },
44
+ },
31
45
  onUpdate: ({ editor }) => {
32
46
  options.onUpdate?.(editor.getJSON());
33
47
  },
@@ -38,3 +52,35 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
38
52
 
39
53
  return editor;
40
54
  }
55
+
56
+ /**
57
+ * Converts TipTap JSON content to Markdown using a headless editor.
58
+ *
59
+ * @param json - TipTap JSONContent
60
+ * @returns Markdown string
61
+ *
62
+ * @example
63
+ * const md = jsonToMarkdown({ type: "doc", content: [...] });
64
+ */
65
+ export function jsonToMarkdown(json: JSONContent): string {
66
+ const editor = new Editor({
67
+ extensions: [
68
+ StarterKit.configure({
69
+ heading: { levels: [1, 2, 3] },
70
+ link: { openOnClick: false, autolink: false },
71
+ }),
72
+ Markdown,
73
+ Table.configure({ resizable: false }),
74
+ TableRow,
75
+ TableCell,
76
+ TableHeader,
77
+ ImageNode,
78
+ MoreBreak,
79
+ ],
80
+ content: json,
81
+ });
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ const md = ((editor as any).storage.markdown.getMarkdown as () => string)();
84
+ editor.destroy();
85
+ return md;
86
+ }
@@ -7,6 +7,7 @@
7
7
  import type { Extensions } from "@tiptap/core";
8
8
  import StarterKit from "@tiptap/starter-kit";
9
9
  import Placeholder from "@tiptap/extension-placeholder";
10
+ import { Markdown } from "tiptap-markdown";
10
11
  import {
11
12
  Table,
12
13
  TableRow,
@@ -42,6 +43,10 @@ export function createEditorExtensions(
42
43
  Placeholder.configure({
43
44
  placeholder: options.placeholder ?? "Write something…",
44
45
  }),
46
+ Markdown.configure({
47
+ transformPastedText: true,
48
+ transformCopiedText: false,
49
+ }),
45
50
  Table.configure({
46
51
  resizable: false,
47
52
  HTMLAttributes: { class: "tiptap-table" },
@@ -11,6 +11,7 @@
11
11
  import { Node, type Editor } from "@tiptap/core";
12
12
  import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
13
13
  import type { EditorView } from "@tiptap/pm/view";
14
+ import { uploadWithMetadata } from "../upload-with-metadata.js";
14
15
 
15
16
  declare module "@tiptap/core" {
16
17
  interface Commands<ReturnType> {
@@ -307,14 +308,7 @@ class ImageNodeView {
307
308
  const file = input.files?.[0];
308
309
  if (!file) return;
309
310
  try {
310
- const formData = new FormData();
311
- formData.append("file", file);
312
- const response = await fetch("/api/upload", {
313
- method: "POST",
314
- body: formData,
315
- });
316
- if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
317
- const data = (await response.json()) as { url: string };
311
+ const data = await uploadWithMetadata(file);
318
312
  this.updateAttrs({ src: data.url });
319
313
  } catch {
320
314
  // Upload failed — keep current image
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { Extension } from "@tiptap/core";
10
10
  import { Plugin, PluginKey } from "@tiptap/pm/state";
11
+ import { uploadWithMetadata } from "../upload-with-metadata.js";
11
12
 
12
13
  const pasteImagePluginKey = new PluginKey("pasteImage");
13
14
 
@@ -81,19 +82,7 @@ async function uploadAndInsertImage(
81
82
  editor.chain().focus().setImage({ src: placeholderUrl }).run();
82
83
 
83
84
  try {
84
- const formData = new FormData();
85
- formData.append("file", file);
86
-
87
- const response = await fetch("/api/upload", {
88
- method: "POST",
89
- body: formData,
90
- });
91
-
92
- if (!response.ok) {
93
- throw new Error(`Upload failed: ${response.status}`);
94
- }
95
-
96
- const data = (await response.json()) as { url: string };
85
+ const data = await uploadWithMetadata(file);
97
86
 
98
87
  // Replace placeholder URL with actual URL in the document
99
88
  const { doc } = editor.state;
@@ -13,6 +13,21 @@ import Suggestion, {
13
13
  } from "@tiptap/suggestion";
14
14
  import type { Editor, Range } from "@tiptap/core";
15
15
 
16
+ // SVG icons (18×18, stroke-based, Lucide style)
17
+ const ICONS = {
18
+ image: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
19
+ divider: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/></svg>`,
20
+ readMore: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m9 18 3 3 3-3"/><path d="m9 6-3-3-3 3"/><path d="M3 6h18"/></svg>`,
21
+ table: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>`,
22
+ code: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
23
+ blockquote: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
24
+ bulletList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
25
+ orderedList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
26
+ h1: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v10"/></svg>`,
27
+ h2: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>`,
28
+ h3: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>`,
29
+ } as const;
30
+
16
31
  interface SlashCommandItem {
17
32
  label: string;
18
33
  icon: string;
@@ -21,116 +36,133 @@ interface SlashCommandItem {
21
36
 
22
37
  const SLASH_COMMANDS: SlashCommandItem[] = [
23
38
  {
24
- label: "Heading 1",
25
- icon: "H1",
39
+ label: "Media",
40
+ icon: ICONS.image,
26
41
  command: (editor, range) => {
27
- editor
28
- .chain()
29
- .focus()
30
- .deleteRange(range)
31
- .toggleHeading({ level: 1 })
32
- .run();
42
+ editor.chain().focus().deleteRange(range).run();
43
+ document.dispatchEvent(
44
+ new CustomEvent("jant:slash-image", { bubbles: true }),
45
+ );
33
46
  },
34
47
  },
35
48
  {
36
- label: "Heading 2",
37
- icon: "H2",
49
+ label: "Divider",
50
+ icon: ICONS.divider,
51
+ command: (editor, range) => {
52
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
53
+ },
54
+ },
55
+ {
56
+ label: "Read More",
57
+ icon: ICONS.readMore,
38
58
  command: (editor, range) => {
39
59
  editor
40
60
  .chain()
41
61
  .focus()
42
62
  .deleteRange(range)
43
- .toggleHeading({ level: 2 })
63
+ .insertContent({ type: "moreBreak" })
44
64
  .run();
45
65
  },
46
66
  },
47
67
  {
48
- label: "Heading 3",
49
- icon: "H3",
68
+ label: "Table",
69
+ icon: ICONS.table,
50
70
  command: (editor, range) => {
51
71
  editor
52
72
  .chain()
53
73
  .focus()
54
74
  .deleteRange(range)
55
- .toggleHeading({ level: 3 })
75
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
56
76
  .run();
57
77
  },
58
78
  },
59
79
  {
60
- label: "Bullet List",
61
- icon: "•",
62
- command: (editor, range) => {
63
- editor.chain().focus().deleteRange(range).toggleBulletList().run();
64
- },
65
- },
66
- {
67
- label: "Ordered List",
68
- icon: "1.",
80
+ label: "Code Block",
81
+ icon: ICONS.code,
69
82
  command: (editor, range) => {
70
- editor.chain().focus().deleteRange(range).toggleOrderedList().run();
83
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
71
84
  },
72
85
  },
73
86
  {
74
87
  label: "Blockquote",
75
- icon: '"',
88
+ icon: ICONS.blockquote,
76
89
  command: (editor, range) => {
77
90
  editor.chain().focus().deleteRange(range).toggleBlockquote().run();
78
91
  },
79
92
  },
80
93
  {
81
- label: "Code Block",
82
- icon: "</>",
94
+ label: "Bullet List",
95
+ icon: ICONS.bulletList,
83
96
  command: (editor, range) => {
84
- editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
97
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
85
98
  },
86
99
  },
87
100
  {
88
- label: "Horizontal Rule",
89
- icon: "—",
101
+ label: "Ordered List",
102
+ icon: ICONS.orderedList,
90
103
  command: (editor, range) => {
91
- editor.chain().focus().deleteRange(range).setHorizontalRule().run();
104
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
92
105
  },
93
106
  },
94
107
  {
95
- label: "Image",
96
- icon: "🖼",
108
+ label: "Heading 1",
109
+ icon: ICONS.h1,
97
110
  command: (editor, range) => {
98
- editor.chain().focus().deleteRange(range).run();
99
- document.dispatchEvent(
100
- new CustomEvent("jant:slash-image", { bubbles: true }),
101
- );
111
+ editor
112
+ .chain()
113
+ .focus()
114
+ .deleteRange(range)
115
+ .toggleHeading({ level: 1 })
116
+ .run();
102
117
  },
103
118
  },
104
119
  {
105
- label: "Table",
106
- icon: "▦",
120
+ label: "Heading 2",
121
+ icon: ICONS.h2,
107
122
  command: (editor, range) => {
108
123
  editor
109
124
  .chain()
110
125
  .focus()
111
126
  .deleteRange(range)
112
- .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
127
+ .toggleHeading({ level: 2 })
113
128
  .run();
114
129
  },
115
130
  },
116
131
  {
117
- label: "Read More",
118
- icon: "↓",
132
+ label: "Heading 3",
133
+ icon: ICONS.h3,
119
134
  command: (editor, range) => {
120
135
  editor
121
136
  .chain()
122
137
  .focus()
123
138
  .deleteRange(range)
124
- .insertContent({ type: "moreBreak" })
139
+ .toggleHeading({ level: 3 })
125
140
  .run();
126
141
  },
127
142
  },
128
143
  ];
129
144
 
145
+ /** Check whether a document already contains a moreBreak node */
146
+ function hasMoreBreak(editor: Editor): boolean {
147
+ let found = false;
148
+ editor.state.doc.descendants((node) => {
149
+ if (node.type.name === "moreBreak") {
150
+ found = true;
151
+ return false; // stop traversal
152
+ }
153
+ return !found;
154
+ });
155
+ return found;
156
+ }
157
+
130
158
  /**
131
159
  * Returns the slash commands list, used by both the extension and the + menu.
160
+ * Omits "Read More" when the document already contains one.
132
161
  */
133
- export function getSlashCommands(): SlashCommandItem[] {
162
+ export function getSlashCommands(editor?: Editor): SlashCommandItem[] {
163
+ if (editor && hasMoreBreak(editor)) {
164
+ return SLASH_COMMANDS.filter((item) => item.label !== "Read More");
165
+ }
134
166
  return SLASH_COMMANDS;
135
167
  }
136
168
 
@@ -139,6 +171,9 @@ let popupEl: HTMLElement | null = null;
139
171
  let selectedIndex = 0;
140
172
  let filteredItems: SlashCommandItem[] = [];
141
173
  let commandFn: ((item: { index: number }) => void) | null = null;
174
+ let editorRef: Editor | null = null;
175
+ let currentRange: Range | null = null;
176
+ let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
142
177
 
143
178
  function createPopup(): HTMLElement {
144
179
  const el = document.createElement("div");
@@ -147,6 +182,34 @@ function createPopup(): HTMLElement {
147
182
  return el;
148
183
  }
149
184
 
185
+ /** Scroll the selected item into view within the popup only (no ancestor scroll) */
186
+ function scrollSelectedIntoView() {
187
+ if (!popupEl) return;
188
+ const selected = popupEl.querySelector(
189
+ ".tiptap-slash-item.is-selected",
190
+ ) as HTMLElement | null;
191
+ if (!selected) return;
192
+ const itemTop = selected.offsetTop;
193
+ const itemBottom = itemTop + selected.offsetHeight;
194
+ const scrollTop = popupEl.scrollTop;
195
+ const viewBottom = scrollTop + popupEl.clientHeight;
196
+ if (itemTop < scrollTop) {
197
+ popupEl.scrollTop = itemTop;
198
+ } else if (itemBottom > viewBottom) {
199
+ popupEl.scrollTop = itemBottom - popupEl.clientHeight;
200
+ }
201
+ }
202
+
203
+ /** Update selection highlight and scroll into view */
204
+ function updateSelection() {
205
+ popupEl
206
+ ?.querySelectorAll(".tiptap-slash-item")
207
+ .forEach((item, i) =>
208
+ item.classList.toggle("is-selected", i === selectedIndex),
209
+ );
210
+ scrollSelectedIntoView();
211
+ }
212
+
150
213
  function renderPopup(
151
214
  items: SlashCommandItem[],
152
215
  onSelect: (index: number) => void,
@@ -175,37 +238,81 @@ function renderPopup(
175
238
  });
176
239
  el.addEventListener("mouseenter", () => {
177
240
  selectedIndex = parseInt(el.dataset.index ?? "0", 10);
178
- popupEl
179
- ?.querySelectorAll(".tiptap-slash-item")
180
- .forEach((item, i) =>
181
- item.classList.toggle("is-selected", i === selectedIndex),
182
- );
241
+ updateSelection();
183
242
  });
184
243
  });
185
244
  }
186
245
 
187
246
  function destroyPopup() {
247
+ if (outsideClickHandler) {
248
+ document.removeEventListener("mousedown", outsideClickHandler, true);
249
+ outsideClickHandler = null;
250
+ }
188
251
  popupEl?.remove();
189
252
  popupEl = null;
190
253
  selectedIndex = 0;
191
254
  filteredItems = [];
192
255
  commandFn = null;
256
+ editorRef = null;
257
+ currentRange = null;
193
258
  }
194
259
 
195
260
  /**
196
261
  * Position the popup relative to the cursor, accounting for dialog containing block.
197
262
  * When a `<dialog>` has CSS animation, it creates a containing block that makes
198
263
  * `position: fixed` relative to the dialog instead of the viewport.
264
+ * Flips above the cursor when there isn't enough space below.
199
265
  */
200
266
  function positionPopup(
201
267
  rect: globalThis.DOMRect,
202
268
  container: HTMLElement | null,
203
269
  ) {
204
270
  if (!popupEl) return;
271
+
272
+ // Reset inline max-height so offsetHeight reflects the natural size
273
+ popupEl.style.maxHeight = "";
274
+
205
275
  const offsetX = container?.getBoundingClientRect().left ?? 0;
206
276
  const offsetY = container?.getBoundingClientRect().top ?? 0;
207
- popupEl.style.left = `${rect.left - offsetX}px`;
208
- popupEl.style.top = `${rect.bottom + 4 - offsetY}px`;
277
+ const containerHeight = container?.clientHeight ?? window.innerHeight;
278
+ const popupHeight = popupEl.offsetHeight;
279
+ const gap = 4;
280
+
281
+ const left = rect.left - offsetX;
282
+ const belowTop = rect.bottom + gap - offsetY;
283
+ const spaceBelow = containerHeight - belowTop;
284
+ const spaceAbove = rect.top - offsetY - gap;
285
+
286
+ popupEl.style.left = `${left}px`;
287
+
288
+ if (popupHeight > spaceBelow && spaceAbove > spaceBelow) {
289
+ // Not enough space below and more room above — flip
290
+ const maxH = Math.min(popupHeight, spaceAbove);
291
+ if (popupHeight > spaceAbove) {
292
+ popupEl.style.maxHeight = `${spaceAbove}px`;
293
+ }
294
+ popupEl.style.top = `${rect.top - offsetY - maxH - gap}px`;
295
+ } else {
296
+ // Show below (constrain if needed)
297
+ if (popupHeight > spaceBelow) {
298
+ popupEl.style.maxHeight = `${spaceBelow}px`;
299
+ }
300
+ popupEl.style.top = `${belowTop}px`;
301
+ }
302
+ }
303
+
304
+ /** Install a click-outside handler to dismiss the suggestion on external clicks */
305
+ function installClickOutside() {
306
+ outsideClickHandler = (e: MouseEvent) => {
307
+ if (!popupEl || popupEl.contains(e.target as Node)) return;
308
+ // Click anywhere outside the popup (including inside the editor) — dismiss
309
+ // by deleting the trigger text so the suggestion plugin deactivates via onExit
310
+ if (editorRef && currentRange) {
311
+ const { state, view } = editorRef;
312
+ view.dispatch(state.tr.delete(currentRange.from, currentRange.to));
313
+ }
314
+ };
315
+ document.addEventListener("mousedown", outsideClickHandler, true);
209
316
  }
210
317
 
211
318
  /**
@@ -219,9 +326,9 @@ export const SlashCommands = Extension.create({
219
326
  suggestion: {
220
327
  char: "/",
221
328
  startOfLine: false,
222
- items: ({ query }: { query: string }) => {
329
+ items: ({ query, editor }: { query: string; editor: Editor }) => {
223
330
  const q = query.toLowerCase();
224
- return SLASH_COMMANDS.filter((item) =>
331
+ return getSlashCommands(editor).filter((item) =>
225
332
  item.label.toLowerCase().includes(q),
226
333
  );
227
334
  },
@@ -238,6 +345,8 @@ export const SlashCommands = Extension.create({
238
345
  popupEl = createPopup();
239
346
  selectedIndex = 0;
240
347
  commandFn = props.command;
348
+ editorRef = props.editor;
349
+ currentRange = props.range;
241
350
  renderPopup(props.items, (index) => props.command({ index }));
242
351
 
243
352
  // Append inside the closest dialog (top-layer) or body
@@ -249,11 +358,14 @@ export const SlashCommands = Extension.create({
249
358
  if (rect) {
250
359
  positionPopup(rect, dialog);
251
360
  }
361
+
362
+ installClickOutside();
252
363
  },
253
364
  onUpdate: (
254
365
  props: SuggestionProps<SlashCommandItem, { index: number }>,
255
366
  ) => {
256
367
  commandFn = props.command;
368
+ currentRange = props.range;
257
369
  renderPopup(props.items, (index) => props.command({ index }));
258
370
  const rect = props.clientRect?.();
259
371
  if (rect) {
@@ -265,30 +377,28 @@ export const SlashCommands = Extension.create({
265
377
  onKeyDown: (props: SuggestionKeyDownProps) => {
266
378
  const { event } = props;
267
379
  if (event.key === "ArrowDown") {
380
+ event.preventDefault();
268
381
  selectedIndex = (selectedIndex + 1) % filteredItems.length;
269
- popupEl
270
- ?.querySelectorAll(".tiptap-slash-item")
271
- .forEach((item, i) =>
272
- item.classList.toggle("is-selected", i === selectedIndex),
273
- );
382
+ updateSelection();
274
383
  return true;
275
384
  }
276
385
  if (event.key === "ArrowUp") {
386
+ event.preventDefault();
277
387
  selectedIndex =
278
388
  (selectedIndex - 1 + filteredItems.length) %
279
389
  filteredItems.length;
280
- popupEl
281
- ?.querySelectorAll(".tiptap-slash-item")
282
- .forEach((item, i) =>
283
- item.classList.toggle("is-selected", i === selectedIndex),
284
- );
390
+ updateSelection();
285
391
  return true;
286
392
  }
287
393
  if (event.key === "Enter") {
394
+ event.preventDefault();
288
395
  commandFn?.({ index: selectedIndex });
289
396
  return true;
290
397
  }
291
398
  if (event.key === "Escape") {
399
+ // Stop propagation to prevent parent dialog from closing
400
+ event.stopPropagation();
401
+ event.preventDefault();
292
402
  destroyPopup();
293
403
  return true;
294
404
  }