@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,482 @@
1
+ /**
2
+ * Custom Image Node with Ghost-Style NodeView
3
+ *
4
+ * Replaces @tiptap/extension-image with a block-level figure that supports:
5
+ * - Caption and alt text editing (Ghost-style inline bar)
6
+ * - Layout variants (regular / wide / full)
7
+ * - Link wrapping, image replacement, and lightbox preview
8
+ * - Toolbar shown on selection
9
+ */
10
+
11
+ import { Node, type Editor } from "@tiptap/core";
12
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
13
+ import type { EditorView } from "@tiptap/pm/view";
14
+ import { uploadWithMetadata } from "../upload-with-metadata.js";
15
+
16
+ declare module "@tiptap/core" {
17
+ interface Commands<ReturnType> {
18
+ image: {
19
+ setImage: (options: {
20
+ src: string;
21
+ alt?: string;
22
+ title?: string;
23
+ caption?: string;
24
+ href?: string;
25
+ layout?: string;
26
+ }) => ReturnType;
27
+ };
28
+ }
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // SVG icon helpers (inline, 16×16)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const ICONS = {
36
+ /** Content-width — centered column */
37
+ regular: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg>`,
38
+ /** Wide — max 1200 px breakout */
39
+ wide: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="1.5" y="4" width="13" height="8" rx="1.5"/></svg>`,
40
+ /** Full — edge-to-edge viewport */
41
+ full: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="0.75" y="3" width="14.5" height="10" rx="1"/><path d="M4 8h8M4 6l-1.5 2L4 10M12 6l1.5 2L12 10"/></svg>`,
42
+ /** Link */
43
+ link: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
44
+ /** Replace / swap */
45
+ replace: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>`,
46
+ /** Expand / fullscreen preview */
47
+ expand: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`,
48
+ } as const;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // NodeView (vanilla DOM)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ class ImageNodeView {
55
+ dom: HTMLElement;
56
+
57
+ private img: HTMLImageElement;
58
+ private figcaption: HTMLElement;
59
+ private captionInput: HTMLInputElement;
60
+ private altBtn: HTMLButtonElement;
61
+ private toolbar: HTMLElement;
62
+ private captionBar: HTMLElement;
63
+ private layoutBtns: Map<string, HTMLButtonElement> = new Map();
64
+
65
+ private node: ProseMirrorNode;
66
+ private view: EditorView;
67
+ private getPos: () => number | undefined;
68
+ private editor: Editor;
69
+
70
+ private editingAlt = false;
71
+
72
+ constructor(
73
+ node: ProseMirrorNode,
74
+ view: EditorView,
75
+ getPos: () => number | undefined,
76
+ editor: Editor,
77
+ ) {
78
+ this.node = node;
79
+ this.view = view;
80
+ this.getPos = getPos;
81
+ this.editor = editor;
82
+
83
+ // --- Build DOM tree ---
84
+ const figure = document.createElement("figure");
85
+ figure.className = "tiptap-image-figure";
86
+ figure.dataset.selected = "false";
87
+ figure.dataset.layout = String(node.attrs.layout || "regular");
88
+ this.dom = figure;
89
+
90
+ // Image container
91
+ const container = document.createElement("div");
92
+ container.className = "tiptap-image-container";
93
+ figure.appendChild(container);
94
+
95
+ // <img>
96
+ const img = document.createElement("img");
97
+ img.src = String(node.attrs.src ?? "");
98
+ img.alt = String(node.attrs.alt ?? "");
99
+ if (node.attrs.title) img.title = String(node.attrs.title);
100
+ img.draggable = false;
101
+ container.appendChild(img);
102
+ this.img = img;
103
+
104
+ // --- Toolbar (shown when selected) ---
105
+ const toolbar = document.createElement("div");
106
+ toolbar.className = "tiptap-image-toolbar";
107
+ container.appendChild(toolbar);
108
+ this.toolbar = toolbar;
109
+
110
+ const layouts: Array<[string, string, string]> = [
111
+ ["regular", ICONS.regular, "Content width"],
112
+ ["wide", ICONS.wide, "Wide \u2014 max 1200px"],
113
+ ["full", ICONS.full, "Full width \u2014 edge to edge"],
114
+ ];
115
+ for (const [value, icon, title] of layouts) {
116
+ if (this.layoutBtns.size > 0) toolbar.appendChild(this.sep());
117
+ const btn = document.createElement("button");
118
+ btn.type = "button";
119
+ btn.innerHTML = icon;
120
+ btn.title = title;
121
+ btn.dataset.layout = value;
122
+ if (value === (node.attrs.layout || "regular"))
123
+ btn.className = "is-active";
124
+ btn.addEventListener("mousedown", (e) => {
125
+ e.preventDefault();
126
+ this.updateAttrs({ layout: value });
127
+ });
128
+ toolbar.appendChild(btn);
129
+ this.layoutBtns.set(value, btn);
130
+ }
131
+
132
+ // Link button
133
+ toolbar.appendChild(this.sep());
134
+ const linkBtn = this.iconBtn(ICONS.link, "Add link");
135
+ linkBtn.addEventListener("mousedown", (e) => {
136
+ e.preventDefault();
137
+ this.handleLink();
138
+ });
139
+ toolbar.appendChild(linkBtn);
140
+
141
+ // Replace button
142
+ toolbar.appendChild(this.sep());
143
+ const replaceBtn = this.iconBtn(ICONS.replace, "Replace image");
144
+ replaceBtn.addEventListener("mousedown", (e) => {
145
+ e.preventDefault();
146
+ this.handleReplace();
147
+ });
148
+ toolbar.appendChild(replaceBtn);
149
+
150
+ // Expand button
151
+ toolbar.appendChild(this.sep());
152
+ const expandBtn = this.iconBtn(ICONS.expand, "Preview fullscreen");
153
+ expandBtn.addEventListener("mousedown", (e) => {
154
+ e.preventDefault();
155
+ this.handleExpand();
156
+ });
157
+ toolbar.appendChild(expandBtn);
158
+
159
+ // --- Caption bar (shown when selected, directly below image) ---
160
+ const captionBar = document.createElement("div");
161
+ captionBar.className = "tiptap-image-caption-bar";
162
+ figure.appendChild(captionBar);
163
+ this.captionBar = captionBar;
164
+
165
+ const captionInput = document.createElement("input");
166
+ captionInput.type = "text";
167
+ captionInput.placeholder = "Add a caption\u2026";
168
+ captionInput.value = String(node.attrs.caption ?? "");
169
+ captionInput.addEventListener("input", () => {
170
+ if (this.editingAlt) {
171
+ this.updateAttrs({ alt: captionInput.value });
172
+ } else {
173
+ this.updateAttrs({ caption: captionInput.value });
174
+ }
175
+ });
176
+ captionInput.addEventListener("keydown", (e) => {
177
+ if (e.key === "Enter") {
178
+ e.preventDefault();
179
+ this.view.focus();
180
+ }
181
+ });
182
+ captionBar.appendChild(captionInput);
183
+ this.captionInput = captionInput;
184
+
185
+ const altBtn = document.createElement("button");
186
+ altBtn.type = "button";
187
+ altBtn.className = "tiptap-image-alt-btn";
188
+ altBtn.textContent = "Alt";
189
+ altBtn.addEventListener("mousedown", (e) => {
190
+ e.preventDefault();
191
+ this.toggleAltMode();
192
+ });
193
+ captionBar.appendChild(altBtn);
194
+ this.altBtn = altBtn;
195
+
196
+ // --- Static figcaption (shown when NOT selected, if caption exists) ---
197
+ const figcaption = document.createElement("figcaption");
198
+ figcaption.className = "tiptap-image-figcaption";
199
+ figcaption.textContent = String(node.attrs.caption ?? "");
200
+ figure.appendChild(figcaption);
201
+ this.figcaption = figcaption;
202
+ }
203
+
204
+ // --- ProseMirror NodeView interface ---
205
+
206
+ update(node: ProseMirrorNode): boolean {
207
+ if (node.type !== this.node.type) return false;
208
+ this.node = node;
209
+
210
+ // Sync DOM with new attrs
211
+ this.img.src = String(node.attrs.src ?? "");
212
+ this.img.alt = String(node.attrs.alt ?? "");
213
+ this.img.title = String(node.attrs.title ?? "");
214
+
215
+ this.dom.dataset.layout = String(node.attrs.layout || "regular");
216
+ this.layoutBtns.forEach((btn, value) => {
217
+ btn.classList.toggle(
218
+ "is-active",
219
+ value === (node.attrs.layout || "regular"),
220
+ );
221
+ });
222
+
223
+ const caption = String(node.attrs.caption ?? "");
224
+ this.figcaption.textContent = caption;
225
+
226
+ // Sync input value (only if user isn't actively editing)
227
+ if (document.activeElement !== this.captionInput) {
228
+ if (this.editingAlt) {
229
+ this.captionInput.value = String(node.attrs.alt ?? "");
230
+ } else {
231
+ this.captionInput.value = caption;
232
+ }
233
+ }
234
+
235
+ return true;
236
+ }
237
+
238
+ selectNode() {
239
+ this.dom.dataset.selected = "true";
240
+ }
241
+
242
+ deselectNode() {
243
+ this.dom.dataset.selected = "false";
244
+ this.editingAlt = false;
245
+ this.altBtn.classList.remove("is-active");
246
+ this.captionInput.placeholder = "Add a caption\u2026";
247
+ this.captionInput.value = String(this.node.attrs.caption ?? "");
248
+ }
249
+
250
+ stopEvent(event: Event): boolean {
251
+ const target = event.target as HTMLElement;
252
+ // Let the NodeView handle events on toolbar, caption bar, and their children
253
+ if (target.closest(".tiptap-image-toolbar")) return true;
254
+ if (target.closest(".tiptap-image-caption-bar")) return true;
255
+ return false;
256
+ }
257
+
258
+ ignoreMutation(): boolean {
259
+ return true;
260
+ }
261
+
262
+ destroy() {
263
+ // No cleanup needed — DOM removed automatically
264
+ }
265
+
266
+ // --- Helpers ---
267
+
268
+ private sep(): HTMLElement {
269
+ const s = document.createElement("span");
270
+ s.className = "tiptap-toolbar-sep";
271
+ return s;
272
+ }
273
+
274
+ private iconBtn(svg: string, title: string): HTMLButtonElement {
275
+ const btn = document.createElement("button");
276
+ btn.type = "button";
277
+ btn.innerHTML = svg;
278
+ btn.title = title;
279
+ return btn;
280
+ }
281
+
282
+ private updateAttrs(attrs: Record<string, unknown>) {
283
+ const pos = this.getPos();
284
+ if (pos === undefined) return;
285
+ const tr = this.view.state.tr.setNodeMarkup(pos, undefined, {
286
+ ...this.node.attrs,
287
+ ...attrs,
288
+ });
289
+ this.view.dispatch(tr);
290
+ }
291
+
292
+ private handleLink() {
293
+ const current = String(this.node.attrs.href ?? "");
294
+ if (current) {
295
+ // Remove existing link
296
+ this.updateAttrs({ href: "" });
297
+ } else {
298
+ const url = globalThis.prompt("Enter URL");
299
+ if (url) this.updateAttrs({ href: url });
300
+ }
301
+ }
302
+
303
+ private handleReplace() {
304
+ const input = document.createElement("input");
305
+ input.type = "file";
306
+ input.accept = "image/*";
307
+ input.addEventListener("change", async () => {
308
+ const file = input.files?.[0];
309
+ if (!file) return;
310
+ try {
311
+ const data = await uploadWithMetadata(file);
312
+ this.updateAttrs({ src: data.url });
313
+ } catch {
314
+ // Upload failed — keep current image
315
+ }
316
+ });
317
+ input.click();
318
+ }
319
+
320
+ private handleExpand() {
321
+ const lightbox = document.querySelector("jant-media-lightbox") as {
322
+ open: (
323
+ images: Array<{ url: string; alt: string }>,
324
+ index: number,
325
+ ) => void;
326
+ } | null;
327
+ if (lightbox) {
328
+ lightbox.open(
329
+ [
330
+ {
331
+ url: String(this.node.attrs.src ?? ""),
332
+ alt: String(this.node.attrs.alt ?? ""),
333
+ },
334
+ ],
335
+ 0,
336
+ );
337
+ }
338
+ }
339
+
340
+ private toggleAltMode() {
341
+ this.editingAlt = !this.editingAlt;
342
+ this.altBtn.classList.toggle("is-active", this.editingAlt);
343
+ if (this.editingAlt) {
344
+ this.captionInput.placeholder = "Add alt text\u2026";
345
+ this.captionInput.value = String(this.node.attrs.alt ?? "");
346
+ } else {
347
+ this.captionInput.placeholder = "Add a caption\u2026";
348
+ this.captionInput.value = String(this.node.attrs.caption ?? "");
349
+ }
350
+ this.captionInput.focus();
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Node Extension
356
+ // ---------------------------------------------------------------------------
357
+
358
+ export const ImageNode = Node.create({
359
+ name: "image",
360
+ group: "block",
361
+ atom: true,
362
+ selectable: true,
363
+ draggable: true,
364
+
365
+ addAttributes() {
366
+ return {
367
+ src: { default: "" },
368
+ alt: { default: "" },
369
+ title: { default: "" },
370
+ caption: { default: "" },
371
+ href: { default: "" },
372
+ layout: { default: "regular" },
373
+ };
374
+ },
375
+
376
+ parseHTML() {
377
+ return [
378
+ {
379
+ tag: "figure[data-image]",
380
+ getAttrs(dom) {
381
+ const el = dom as HTMLElement;
382
+ const img = el.querySelector("img");
383
+ const figcaption = el.querySelector("figcaption");
384
+ const link = el.querySelector("a");
385
+ return {
386
+ src: img?.getAttribute("src") ?? "",
387
+ alt: img?.getAttribute("alt") ?? "",
388
+ title: img?.getAttribute("title") ?? "",
389
+ caption: figcaption?.textContent ?? "",
390
+ href: link?.getAttribute("href") ?? "",
391
+ layout: el.dataset.layout ?? "regular",
392
+ };
393
+ },
394
+ },
395
+ {
396
+ tag: "figure",
397
+ getAttrs(dom) {
398
+ const el = dom as HTMLElement;
399
+ const img = el.querySelector("img");
400
+ if (!img) return false;
401
+ const figcaption = el.querySelector("figcaption");
402
+ const link = el.querySelector("a");
403
+ return {
404
+ src: img.getAttribute("src") ?? "",
405
+ alt: img.getAttribute("alt") ?? "",
406
+ title: img.getAttribute("title") ?? "",
407
+ caption: figcaption?.textContent ?? "",
408
+ href: link?.getAttribute("href") ?? "",
409
+ layout: el.dataset.layout ?? "regular",
410
+ };
411
+ },
412
+ },
413
+ {
414
+ tag: "img[src]",
415
+ getAttrs(dom) {
416
+ const el = dom as HTMLImageElement;
417
+ return {
418
+ src: el.getAttribute("src") ?? "",
419
+ alt: el.getAttribute("alt") ?? "",
420
+ title: el.getAttribute("title") ?? "",
421
+ };
422
+ },
423
+ },
424
+ ];
425
+ },
426
+
427
+ renderHTML({ node }) {
428
+ const attrs: Record<string, string> = {};
429
+ if (node.attrs.layout && node.attrs.layout !== "regular") {
430
+ attrs["data-layout"] = node.attrs.layout;
431
+ }
432
+ attrs["data-image"] = "";
433
+
434
+ const imgAttrs: Record<string, string> = { src: node.attrs.src };
435
+ if (node.attrs.alt) imgAttrs.alt = node.attrs.alt;
436
+ if (node.attrs.title) imgAttrs.title = node.attrs.title;
437
+
438
+ const imgNode: [string, Record<string, string>] = ["img", imgAttrs];
439
+
440
+ const children: Array<
441
+ | [string, Record<string, string>]
442
+ | [string, Record<string, string>, ...unknown[]]
443
+ | string
444
+ > = [];
445
+
446
+ if (node.attrs.href) {
447
+ children.push(["a", { href: node.attrs.href }, imgNode]);
448
+ } else {
449
+ children.push(imgNode);
450
+ }
451
+
452
+ if (node.attrs.caption) {
453
+ children.push(["figcaption", {}, node.attrs.caption]);
454
+ }
455
+
456
+ return ["figure", attrs, ...children];
457
+ },
458
+
459
+ addCommands() {
460
+ return {
461
+ setImage:
462
+ (options) =>
463
+ ({ commands }) => {
464
+ return commands.insertContent({
465
+ type: this.name,
466
+ attrs: options,
467
+ });
468
+ },
469
+ };
470
+ },
471
+
472
+ addNodeView() {
473
+ return ({ node, view, getPos, editor }) => {
474
+ return new ImageNodeView(
475
+ node,
476
+ view,
477
+ getPos as () => number | undefined,
478
+ editor,
479
+ );
480
+ };
481
+ },
482
+ });