@jant/core 0.3.35 → 0.3.37

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 (307) 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/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Link Toolbar Extension
3
+ *
4
+ * Floating toolbar for link editing with two modes:
5
+ * - Input mode: light popup with URL field + confirm button (speech-bubble arrow)
6
+ * - Preview mode: dark tooltip showing truncated URL + edit/delete buttons
7
+ *
8
+ * Replaces the browser prompt() dialog for link creation.
9
+ */
10
+
11
+ import { Extension } from "@tiptap/core";
12
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
13
+ import type { EditorState } from "@tiptap/pm/state";
14
+ import type { EditorView } from "@tiptap/pm/view";
15
+
16
+ const linkToolbarKey = new PluginKey("linkToolbar");
17
+
18
+ type Mode = "hidden" | "input" | "preview";
19
+ let currentMode: Mode = "hidden";
20
+
21
+ /** Returns true when the link input popup is visible. Used by bubble menu to hide itself. */
22
+ export function isLinkToolbarInputActive(): boolean {
23
+ return currentMode === "input";
24
+ }
25
+
26
+ // SVG icons (14×14 for preview buttons, 16×16 for confirm)
27
+ const ICON_ENTER = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>`;
28
+ const ICON_EDIT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>`;
29
+ const ICON_TRASH = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`;
30
+
31
+ interface LinkRange {
32
+ from: number;
33
+ to: number;
34
+ href: string;
35
+ }
36
+
37
+ /** Find the extent of a link mark around the cursor position. */
38
+ function getLinkRange(state: EditorState): LinkRange | null {
39
+ const { selection } = state;
40
+ if (!selection.empty) return null;
41
+
42
+ const $pos = selection.$from;
43
+ const linkType = state.schema.marks.link;
44
+ if (!linkType) return null;
45
+
46
+ const marks = $pos.marks();
47
+ const linkMark = marks.find((m) => m.type === linkType);
48
+ if (!linkMark) return null;
49
+
50
+ // Walk the parent node's children to find the text range covered by this link
51
+ const parent = $pos.parent;
52
+ const parentOffset = $pos.start();
53
+ let from = 0;
54
+ let to = 0;
55
+ let found = false;
56
+ let offset = 0;
57
+
58
+ for (let i = 0; i < parent.childCount; i++) {
59
+ const child = parent.child(i);
60
+ const childFrom = parentOffset + offset;
61
+ const childTo = childFrom + child.nodeSize;
62
+
63
+ if (
64
+ child.marks.some(
65
+ (m) => m.type === linkType && m.attrs.href === linkMark.attrs.href,
66
+ )
67
+ ) {
68
+ if (!found) {
69
+ from = childFrom;
70
+ found = true;
71
+ }
72
+ to = childTo;
73
+ } else if (found) {
74
+ break;
75
+ }
76
+
77
+ offset += child.nodeSize;
78
+ }
79
+
80
+ if (!found) return null;
81
+ return { from, to, href: linkMark.attrs.href as string };
82
+ }
83
+
84
+ export const LinkToolbar = Extension.create({
85
+ name: "linkToolbar",
86
+
87
+ addProseMirrorPlugins() {
88
+ const editor = this.editor;
89
+
90
+ // DOM elements
91
+ let inputEl: HTMLElement | null = null;
92
+ let previewEl: HTMLElement | null = null;
93
+ let inputField: HTMLInputElement | null = null;
94
+
95
+ // State
96
+ let savedFrom = 0;
97
+ let savedTo = 0;
98
+ let suppressNextUpdate = false;
99
+ let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
100
+
101
+ function createElements() {
102
+ // --- Input popup ---
103
+ inputEl = document.createElement("div");
104
+ inputEl.className = "tiptap-link-input";
105
+ inputEl.style.display = "none";
106
+
107
+ inputField = document.createElement("input");
108
+ inputField.type = "url";
109
+ inputField.className = "tiptap-link-input-field";
110
+ inputField.placeholder = "https://";
111
+
112
+ const confirmBtn = document.createElement("button");
113
+ confirmBtn.type = "button";
114
+ confirmBtn.className = "tiptap-link-input-confirm";
115
+ confirmBtn.innerHTML = ICON_ENTER;
116
+ confirmBtn.title = "Apply link";
117
+
118
+ inputEl.appendChild(inputField);
119
+ inputEl.appendChild(confirmBtn);
120
+
121
+ // Input key handling
122
+ inputField.addEventListener("keydown", (e) => {
123
+ if (e.key === "Enter") {
124
+ e.preventDefault();
125
+ confirmLink();
126
+ } else if (e.key === "Escape") {
127
+ e.preventDefault();
128
+ hideAll();
129
+ editor.commands.focus();
130
+ }
131
+ });
132
+
133
+ // Confirm button
134
+ confirmBtn.addEventListener("mousedown", (e) => {
135
+ e.preventDefault();
136
+ confirmLink();
137
+ });
138
+
139
+ // Prevent input popup clicks from bubbling
140
+ inputEl.addEventListener("mousedown", (e) => {
141
+ e.stopPropagation();
142
+ });
143
+
144
+ // --- Preview tooltip ---
145
+ previewEl = document.createElement("div");
146
+ previewEl.className = "tiptap-link-preview";
147
+ previewEl.style.display = "none";
148
+
149
+ const urlSpan = document.createElement("span");
150
+ urlSpan.className = "tiptap-link-preview-url";
151
+
152
+ const editBtn = document.createElement("button");
153
+ editBtn.type = "button";
154
+ editBtn.className = "tiptap-link-preview-btn";
155
+ editBtn.innerHTML = ICON_EDIT;
156
+ editBtn.title = "Edit link";
157
+
158
+ const deleteBtn = document.createElement("button");
159
+ deleteBtn.type = "button";
160
+ deleteBtn.className = "tiptap-link-preview-btn";
161
+ deleteBtn.innerHTML = ICON_TRASH;
162
+ deleteBtn.title = "Remove link";
163
+
164
+ previewEl.appendChild(urlSpan);
165
+ previewEl.appendChild(editBtn);
166
+ previewEl.appendChild(deleteBtn);
167
+
168
+ // Edit button — switch to input with current href
169
+ editBtn.addEventListener("mousedown", (e) => {
170
+ e.preventDefault();
171
+ const url = urlSpan.textContent ?? "";
172
+ const range = getLinkRange(editor.state);
173
+ if (range) {
174
+ showInput(editor.view, url, range.from, range.to);
175
+ }
176
+ });
177
+
178
+ // Delete button — remove link
179
+ deleteBtn.addEventListener("mousedown", (e) => {
180
+ e.preventDefault();
181
+ hideAll();
182
+ editor.chain().focus().unsetLink().run();
183
+ });
184
+
185
+ // Prevent preview clicks from bubbling
186
+ previewEl.addEventListener("mousedown", (e) => {
187
+ e.stopPropagation();
188
+ });
189
+ }
190
+
191
+ function positionAbove(
192
+ el: HTMLElement,
193
+ view: EditorView,
194
+ from: number,
195
+ to: number,
196
+ ) {
197
+ const start = view.coordsAtPos(from);
198
+ const end = view.coordsAtPos(to);
199
+ const cx = (start.left + end.right) / 2;
200
+ const top = start.top;
201
+
202
+ const dialog = view.dom.closest("dialog");
203
+ const offsetX = dialog?.getBoundingClientRect().left ?? 0;
204
+ const offsetY = dialog?.getBoundingClientRect().top ?? 0;
205
+
206
+ el.style.display = "flex";
207
+ const rect = el.getBoundingClientRect();
208
+ el.style.left = `${cx - rect.width / 2 - offsetX}px`;
209
+ el.style.top = `${top - rect.height - 8 - offsetY}px`;
210
+ }
211
+
212
+ function showInput(
213
+ view: EditorView,
214
+ href: string,
215
+ from?: number,
216
+ to?: number,
217
+ ) {
218
+ if (!inputEl || !inputField) return;
219
+
220
+ // Save selection range
221
+ if (from !== undefined && to !== undefined) {
222
+ savedFrom = from;
223
+ savedTo = to;
224
+ } else {
225
+ savedFrom = view.state.selection.from;
226
+ savedTo = view.state.selection.to;
227
+ }
228
+
229
+ // Hide preview if showing
230
+ if (previewEl) previewEl.style.display = "none";
231
+
232
+ currentMode = "input";
233
+ inputField.value = href;
234
+ positionAbove(inputEl, view, savedFrom, savedTo);
235
+
236
+ // Focus field after a tick so positioning is settled
237
+ const field = inputField;
238
+ requestAnimationFrame(() => {
239
+ field.focus();
240
+ field.select();
241
+ });
242
+
243
+ // Register outside-click handler
244
+ removeOutsideClickHandler();
245
+ outsideClickHandler = (e: MouseEvent) => {
246
+ if (inputEl && !inputEl.contains(e.target as Node)) {
247
+ hideAll();
248
+ // Don't refocus editor here — user clicked somewhere intentionally
249
+ }
250
+ };
251
+ // Use setTimeout so the current click doesn't immediately trigger it
252
+ setTimeout(() => {
253
+ if (outsideClickHandler) {
254
+ document.addEventListener("mousedown", outsideClickHandler, true);
255
+ }
256
+ }, 0);
257
+ }
258
+
259
+ function showPreview(view: EditorView, range: LinkRange) {
260
+ if (!previewEl) return;
261
+
262
+ currentMode = "preview";
263
+ const urlSpan = previewEl.querySelector(".tiptap-link-preview-url");
264
+ if (urlSpan) {
265
+ // Truncate display URL
266
+ const display =
267
+ range.href.length > 40 ? range.href.slice(0, 40) + "…" : range.href;
268
+ urlSpan.textContent = display;
269
+ urlSpan.setAttribute("title", range.href);
270
+ }
271
+
272
+ positionAbove(previewEl, view, range.from, range.to);
273
+ }
274
+
275
+ function hideAll() {
276
+ if (inputEl) inputEl.style.display = "none";
277
+ if (previewEl) previewEl.style.display = "none";
278
+ currentMode = "hidden";
279
+ removeOutsideClickHandler();
280
+ }
281
+
282
+ function removeOutsideClickHandler() {
283
+ if (outsideClickHandler) {
284
+ document.removeEventListener("mousedown", outsideClickHandler, true);
285
+ outsideClickHandler = null;
286
+ }
287
+ }
288
+
289
+ function confirmLink() {
290
+ if (!inputField) return;
291
+ const url = inputField.value.trim();
292
+ hideAll();
293
+
294
+ if (url) {
295
+ // Restore selection and apply link
296
+ editor
297
+ .chain()
298
+ .focus()
299
+ .setTextSelection({ from: savedFrom, to: savedTo })
300
+ .setLink({ href: url })
301
+ .run();
302
+ } else {
303
+ // Empty URL — remove link if one existed
304
+ editor
305
+ .chain()
306
+ .focus()
307
+ .setTextSelection({ from: savedFrom, to: savedTo })
308
+ .unsetLink()
309
+ .run();
310
+ }
311
+
312
+ // Suppress the next update so the newly-created link doesn't trigger preview
313
+ suppressNextUpdate = true;
314
+ }
315
+
316
+ return [
317
+ new Plugin({
318
+ key: linkToolbarKey,
319
+ view(editorView) {
320
+ createElements();
321
+ const dialog = editorView.dom.closest("dialog");
322
+ if (inputEl) (dialog ?? document.body).appendChild(inputEl);
323
+ if (previewEl) (dialog ?? document.body).appendChild(previewEl);
324
+
325
+ // Listen for bubble menu link button
326
+ const handler = () => {
327
+ showInput(editorView, "");
328
+ };
329
+ editorView.dom.addEventListener("tiptap:open-link-input", handler);
330
+
331
+ return {
332
+ update(view) {
333
+ if (suppressNextUpdate) {
334
+ suppressNextUpdate = false;
335
+ return;
336
+ }
337
+
338
+ // While input is open, just reposition
339
+ if (currentMode === "input") {
340
+ if (inputEl) {
341
+ positionAbove(inputEl, view, savedFrom, savedTo);
342
+ }
343
+ return;
344
+ }
345
+
346
+ // Detect link under cursor for preview mode
347
+ const range = getLinkRange(view.state);
348
+ if (range) {
349
+ showPreview(view, range);
350
+ } else if (currentMode === "preview") {
351
+ hideAll();
352
+ }
353
+ },
354
+ destroy() {
355
+ editorView.dom.removeEventListener(
356
+ "tiptap:open-link-input",
357
+ handler,
358
+ );
359
+ removeOutsideClickHandler();
360
+ inputEl?.remove();
361
+ previewEl?.remove();
362
+ inputEl = null;
363
+ previewEl = null;
364
+ currentMode = "hidden";
365
+ },
366
+ };
367
+ },
368
+ }),
369
+ ];
370
+ },
371
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MoreBreak Node Extension
3
+ *
4
+ * Custom Tiptap node that renders as a dashed "Read More" separator.
5
+ * Atom node — not editable, but selectable and deletable.
6
+ * Server-side renders to <!--more--> for excerpt splitting.
7
+ */
8
+
9
+ import { Node } from "@tiptap/core";
10
+
11
+ declare module "@tiptap/core" {
12
+ interface Commands<ReturnType> {
13
+ moreBreak: {
14
+ insertMoreBreak: () => ReturnType;
15
+ };
16
+ }
17
+ }
18
+
19
+ export const MoreBreak = Node.create({
20
+ name: "moreBreak",
21
+ group: "block",
22
+ atom: true,
23
+ selectable: true,
24
+ draggable: false,
25
+
26
+ parseHTML() {
27
+ return [{ tag: "div[data-more-break]" }];
28
+ },
29
+
30
+ renderHTML() {
31
+ return [
32
+ "div",
33
+ {
34
+ "data-more-break": "",
35
+ class: "tiptap-more-break",
36
+ },
37
+ "Read More ↓",
38
+ ];
39
+ },
40
+
41
+ addCommands() {
42
+ return {
43
+ insertMoreBreak:
44
+ () =>
45
+ ({ commands }) => {
46
+ return commands.insertContent({ type: this.name });
47
+ },
48
+ };
49
+ },
50
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Paste Image Extension
3
+ *
4
+ * Intercepts paste events containing images and either:
5
+ * - Uploads inline (if post has a title → image becomes part of body)
6
+ * - Dispatches as attachment (if no title → goes to attachment strip)
7
+ */
8
+
9
+ import { Extension } from "@tiptap/core";
10
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
11
+ import { uploadWithMetadata } from "../upload-with-metadata.js";
12
+
13
+ const pasteImagePluginKey = new PluginKey("pasteImage");
14
+
15
+ export const PasteImage = Extension.create({
16
+ name: "pasteImage",
17
+
18
+ addStorage() {
19
+ return {
20
+ hasTitle: false,
21
+ };
22
+ },
23
+
24
+ addProseMirrorPlugins() {
25
+ const extension = this;
26
+
27
+ return [
28
+ new Plugin({
29
+ key: pasteImagePluginKey,
30
+ props: {
31
+ handlePaste(view, event) {
32
+ const items = event.clipboardData?.items;
33
+ if (!items) return false;
34
+
35
+ const imageFiles: File[] = [];
36
+ for (const item of items) {
37
+ if (item.type.startsWith("image/")) {
38
+ const file = item.getAsFile();
39
+ if (file) imageFiles.push(file);
40
+ }
41
+ }
42
+
43
+ if (imageFiles.length === 0) return false;
44
+
45
+ event.preventDefault();
46
+
47
+ const hasTitle = extension.storage.hasTitle;
48
+
49
+ if (hasTitle) {
50
+ // Upload and insert inline
51
+ for (const file of imageFiles) {
52
+ uploadAndInsertImage(file, extension.editor);
53
+ }
54
+ } else {
55
+ // Dispatch as attachment (existing flow)
56
+ const files = imageFiles.map((file) => ({
57
+ file,
58
+ clientId: crypto.randomUUID(),
59
+ }));
60
+ document.dispatchEvent(
61
+ new CustomEvent("jant:files-selected", {
62
+ bubbles: true,
63
+ detail: { files },
64
+ }),
65
+ );
66
+ }
67
+
68
+ return true;
69
+ },
70
+ },
71
+ }),
72
+ ];
73
+ },
74
+ });
75
+
76
+ async function uploadAndInsertImage(
77
+ file: File,
78
+ editor: import("@tiptap/core").Editor,
79
+ ) {
80
+ // Insert placeholder
81
+ const placeholderUrl = URL.createObjectURL(file);
82
+ editor.chain().focus().setImage({ src: placeholderUrl }).run();
83
+
84
+ try {
85
+ const data = await uploadWithMetadata(file);
86
+
87
+ // Replace placeholder URL with actual URL in the document
88
+ const { doc } = editor.state;
89
+ let replaced = false;
90
+ doc.descendants((node, pos) => {
91
+ if (
92
+ replaced ||
93
+ node.type.name !== "image" ||
94
+ node.attrs.src !== placeholderUrl
95
+ ) {
96
+ return;
97
+ }
98
+
99
+ editor
100
+ .chain()
101
+ .focus()
102
+ .command(({ tr }) => {
103
+ tr.setNodeMarkup(pos, undefined, {
104
+ ...node.attrs,
105
+ src: data.url,
106
+ });
107
+ return true;
108
+ })
109
+ .run();
110
+ replaced = true;
111
+ });
112
+ } catch {
113
+ // Remove the placeholder image on failure
114
+ const { doc } = editor.state;
115
+ doc.descendants((node, pos) => {
116
+ if (node.type.name === "image" && node.attrs.src === placeholderUrl) {
117
+ editor
118
+ .chain()
119
+ .command(({ tr }) => {
120
+ tr.delete(pos, pos + node.nodeSize);
121
+ return true;
122
+ })
123
+ .run();
124
+ }
125
+ });
126
+ } finally {
127
+ URL.revokeObjectURL(placeholderUrl);
128
+ }
129
+ }