@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,1813 @@
1
+ /**
2
+ * Compose Editor
3
+ *
4
+ * Format-specific content editing sub-component for the compose dialog.
5
+ * Handles note/link/quote fields, star rating, attached text panel,
6
+ * file attachments with thumbnail strip, and alt text editing.
7
+ *
8
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
9
+ */
10
+
11
+ import { LitElement, html, nothing } from "lit";
12
+ import { classMap } from "lit/directives/class-map.js";
13
+ import { unsafeSVG } from "lit/directives/unsafe-svg.js";
14
+ import type { Editor, JSONContent } from "@tiptap/core";
15
+ import Sortable from "sortablejs";
16
+ import type {
17
+ ComposeFormat,
18
+ ComposeLabels,
19
+ ComposeAttachment,
20
+ AttachedTextItem,
21
+ } from "./compose-types.js";
22
+ import {
23
+ UPLOAD_ACCEPT,
24
+ getMediaCategory,
25
+ validateUploadFile,
26
+ } from "../../lib/upload.js";
27
+ import type { MediaCategory } from "../../lib/upload.js";
28
+ import { showToast } from "../toast.js";
29
+ import { createTiptapEditor } from "../tiptap/create-editor.js";
30
+
31
+ export class JantComposeEditor extends LitElement {
32
+ static properties = {
33
+ format: { type: String },
34
+ labels: { type: Object },
35
+ uploadMaxFileSize: { type: Number },
36
+ _title: { state: true },
37
+ _bodyJson: { state: true },
38
+ _url: { state: true },
39
+ _quoteText: { state: true },
40
+ _quoteAuthor: { state: true },
41
+ _rating: { state: true },
42
+ _showTitle: { state: true },
43
+ _showRating: { state: true },
44
+ _attachedTexts: { state: true },
45
+ _attachments: { state: true },
46
+ _attachmentOrder: { state: true },
47
+ _showAltPanel: { state: true },
48
+ _altPanelIndex: { state: true },
49
+ _showEmojiPicker: { state: true },
50
+ };
51
+
52
+ declare format: ComposeFormat;
53
+ declare labels: ComposeLabels;
54
+ declare uploadMaxFileSize: number;
55
+ declare _title: string;
56
+ declare _bodyJson: JSONContent | null;
57
+ declare _url: string;
58
+ declare _quoteText: string;
59
+ declare _quoteAuthor: string;
60
+ declare _rating: number;
61
+ declare _showTitle: boolean;
62
+ declare _showRating: boolean;
63
+ declare _attachedTexts: AttachedTextItem[];
64
+ declare _attachments: ComposeAttachment[];
65
+ declare _attachmentOrder: string[];
66
+ declare _showAltPanel: boolean;
67
+ declare _altPanelIndex: number;
68
+ declare _showEmojiPicker: boolean;
69
+
70
+ private _editor: Editor | null = null;
71
+ private _fileInput: HTMLInputElement | null = null;
72
+ private _lastFocusedField: HTMLTextAreaElement | HTMLInputElement | null =
73
+ null;
74
+ private _emojiPickerEl: HTMLElement | null = null;
75
+ private _emojiContainer: HTMLElement | null = null;
76
+ private _onDocClickBound = this._onDocumentClick.bind(this);
77
+ private _scrollBufferApplied = false;
78
+ private _suppressAttachedTextOpenUntil = 0;
79
+ #sortable: { destroy(): void } | null = null;
80
+ #revertNextSibling: globalThis.Node | null = null;
81
+
82
+ createRenderRoot() {
83
+ return this;
84
+ }
85
+
86
+ constructor() {
87
+ super();
88
+ this.format = "note";
89
+ this.labels = {} as ComposeLabels;
90
+ this.uploadMaxFileSize = 500;
91
+ this._title = "";
92
+ this._bodyJson = null;
93
+ this._url = "";
94
+ this._quoteText = "";
95
+ this._quoteAuthor = "";
96
+ this._rating = 0;
97
+ this._showTitle = false;
98
+ this._showRating = false;
99
+ this._attachedTexts = [];
100
+ this._attachments = [];
101
+ this._attachmentOrder = [];
102
+ this._showAltPanel = false;
103
+ this._altPanelIndex = 0;
104
+ this._showEmojiPicker = false;
105
+ }
106
+
107
+ connectedCallback() {
108
+ super.connectedCallback();
109
+ document.addEventListener("jant:slash-image", this._onSlashImage);
110
+ }
111
+
112
+ disconnectedCallback() {
113
+ super.disconnectedCallback();
114
+ this._editor?.destroy();
115
+ this._editor = null;
116
+ this.#sortable?.destroy();
117
+ this.#sortable = null;
118
+ document.removeEventListener("jant:slash-image", this._onSlashImage);
119
+ document.removeEventListener("click", this._onDocClickBound, true);
120
+ this._emojiContainer?.remove();
121
+ }
122
+
123
+ private _onSlashImage = () => {
124
+ // Skip when fullscreen is open — it has its own handler
125
+ if (document.querySelector(".compose-fullscreen-dialog[open]")) return;
126
+ if (!this._editor) return;
127
+ this._triggerSlashImagePicker();
128
+ };
129
+
130
+ private _slashImageInput: HTMLInputElement | null = null;
131
+
132
+ private _triggerSlashImagePicker() {
133
+ if (!this._slashImageInput) {
134
+ this._slashImageInput = document.createElement("input");
135
+ this._slashImageInput.type = "file";
136
+ this._slashImageInput.accept = "image/*";
137
+ this._slashImageInput.style.display = "none";
138
+ this._slashImageInput.addEventListener("change", () => {
139
+ const file = this._slashImageInput?.files?.[0];
140
+ if (file && this._editor) {
141
+ this._uploadAndInsertImage(file);
142
+ }
143
+ if (this._slashImageInput) this._slashImageInput.value = "";
144
+ });
145
+ document.body.appendChild(this._slashImageInput);
146
+ }
147
+ this._slashImageInput.click();
148
+ }
149
+
150
+ private async _uploadAndInsertImage(file: File) {
151
+ if (!this._editor) return;
152
+
153
+ const placeholderUrl = URL.createObjectURL(file);
154
+ this._editor.chain().focus().setImage({ src: placeholderUrl }).run();
155
+
156
+ try {
157
+ const formData = new FormData();
158
+ formData.append("file", file);
159
+ const response = await fetch("/api/upload", {
160
+ method: "POST",
161
+ body: formData,
162
+ });
163
+ if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
164
+ const data = (await response.json()) as { url: string };
165
+
166
+ const { doc } = this._editor.state;
167
+ let replaced = false;
168
+ doc.descendants((node, pos) => {
169
+ if (
170
+ replaced ||
171
+ node.type.name !== "image" ||
172
+ node.attrs.src !== placeholderUrl
173
+ )
174
+ return;
175
+ this._editor
176
+ ?.chain()
177
+ .focus()
178
+ .command(({ tr }) => {
179
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: data.url });
180
+ return true;
181
+ })
182
+ .run();
183
+ replaced = true;
184
+ });
185
+ } catch {
186
+ const { doc } = this._editor.state;
187
+ doc.descendants((node, pos) => {
188
+ if (node.type.name === "image" && node.attrs.src === placeholderUrl) {
189
+ this._editor
190
+ ?.chain()
191
+ .command(({ tr }) => {
192
+ tr.delete(pos, pos + node.nodeSize);
193
+ return true;
194
+ })
195
+ .run();
196
+ }
197
+ });
198
+ } finally {
199
+ URL.revokeObjectURL(placeholderUrl);
200
+ }
201
+ }
202
+
203
+ private _isEmptyDoc(json: JSONContent): boolean {
204
+ if (!json.content || json.content.length === 0) return true;
205
+ return json.content.every(
206
+ (node) =>
207
+ node.type === "paragraph" &&
208
+ (!node.content || node.content.length === 0),
209
+ );
210
+ }
211
+
212
+ getData() {
213
+ const body =
214
+ this._bodyJson && !this._isEmptyDoc(this._bodyJson)
215
+ ? JSON.stringify(this._bodyJson)
216
+ : "";
217
+ const shared = {
218
+ rating: this._rating,
219
+ attachedTexts: this._attachedTexts,
220
+ attachments: this._attachments,
221
+ attachmentOrder: this._attachmentOrder,
222
+ };
223
+
224
+ switch (this.format) {
225
+ case "link":
226
+ return {
227
+ ...shared,
228
+ title: this._title,
229
+ body,
230
+ url: this._url,
231
+ quoteText: "",
232
+ quoteAuthor: "",
233
+ };
234
+ case "quote":
235
+ return {
236
+ ...shared,
237
+ title: "",
238
+ body,
239
+ url: this._url,
240
+ quoteText: this._quoteText,
241
+ quoteAuthor: this._quoteAuthor,
242
+ };
243
+ default:
244
+ return {
245
+ ...shared,
246
+ title: this._showTitle ? this._title : "",
247
+ body,
248
+ url: "",
249
+ quoteText: "",
250
+ quoteAuthor: "",
251
+ };
252
+ }
253
+ }
254
+
255
+ reset() {
256
+ this._title = "";
257
+ this._bodyJson = null;
258
+ this._editor?.commands.clearContent();
259
+ this._url = "";
260
+ this._quoteText = "";
261
+ this._quoteAuthor = "";
262
+ this._rating = 0;
263
+ this._showTitle = false;
264
+ this._showRating = false;
265
+ this._attachedTexts = [];
266
+ // Revoke preview URLs before clearing
267
+ for (const a of this._attachments) {
268
+ URL.revokeObjectURL(a.previewUrl);
269
+ }
270
+ this._attachments = [];
271
+ this._attachmentOrder = [];
272
+ this._showAltPanel = false;
273
+ this._altPanelIndex = 0;
274
+ this.closeEmojiPicker();
275
+ }
276
+
277
+ updateAttachmentStatus(
278
+ clientId: string,
279
+ status: ComposeAttachment["status"],
280
+ mediaId: string | null,
281
+ error: string | null,
282
+ ) {
283
+ this._attachments = this._attachments.map((a) =>
284
+ a.clientId === clientId ? { ...a, status, mediaId, error } : a,
285
+ );
286
+ }
287
+
288
+ updateAttachmentPreview(clientId: string, file: File) {
289
+ this._attachments = this._attachments.map((a) => {
290
+ if (a.clientId !== clientId) return a;
291
+ URL.revokeObjectURL(a.previewUrl);
292
+ return { ...a, file, previewUrl: URL.createObjectURL(file) };
293
+ });
294
+ }
295
+
296
+ updateAttachmentProgress(clientId: string, progress: number) {
297
+ this._attachments = this._attachments.map((a) =>
298
+ a.clientId === clientId ? { ...a, progress } : a,
299
+ );
300
+ }
301
+
302
+ focusInput() {
303
+ if (this.format === "link") {
304
+ this.querySelector<HTMLElement>('.compose-input[type="url"]')?.focus();
305
+ } else if (this.format === "quote") {
306
+ this.querySelector<HTMLElement>(".compose-quote-text")?.focus();
307
+ } else {
308
+ this._editor?.commands.focus();
309
+ }
310
+ }
311
+
312
+ private _initEditor() {
313
+ const container = this.querySelector<HTMLElement>(".compose-tiptap-body");
314
+ if (!container || this._editor) return;
315
+
316
+ this._editor = createTiptapEditor({
317
+ element: container,
318
+ placeholder:
319
+ this.format === "note"
320
+ ? this.labels.bodyPlaceholder
321
+ : this.labels.thoughtsPlaceholder,
322
+ content: this._bodyJson,
323
+ onUpdate: (json) => {
324
+ this._bodyJson = json;
325
+ this._ensureScrollBuffer();
326
+ },
327
+ onFocus: () => {
328
+ this._lastFocusedField = null;
329
+ },
330
+ });
331
+
332
+ // Lock editor min-height once so new lines fill existing space
333
+ // instead of growing the dialog line-by-line.
334
+ this._scrollBufferApplied = false;
335
+ const dom = this._editor.view.dom as HTMLElement;
336
+ const last = dom.lastElementChild as HTMLElement | null;
337
+ const contentH = last ? last.offsetTop + last.offsetHeight : 0;
338
+ const buffer = this.format !== "note" ? 60 : 120;
339
+ dom.style.minHeight = `${contentH + buffer}px`;
340
+ }
341
+
342
+ /**
343
+ * One-time: adds bottom padding for scroll buffer once the
344
+ * compose-body starts scrolling. Since the dialog is already at
345
+ * max-height by that point, the extra padding doesn't grow it.
346
+ */
347
+ private _ensureScrollBuffer() {
348
+ if (this._scrollBufferApplied) return;
349
+ const dom = this._editor?.view?.dom as HTMLElement | undefined;
350
+ if (!dom) return;
351
+ const body = this.querySelector(".compose-body") as HTMLElement | null;
352
+ if (!body) return;
353
+ if (body.scrollHeight > body.clientHeight + 20) {
354
+ dom.style.paddingBottom = "80px";
355
+ this._scrollBufferApplied = true;
356
+ }
357
+ }
358
+
359
+ private _destroyEditor() {
360
+ this._editor?.destroy();
361
+ this._editor = null;
362
+ }
363
+
364
+ /** Content-relevant properties that trigger a change event for draft auto-save */
365
+ private static _CONTENT_PROPS = new Set([
366
+ "_title",
367
+ "_bodyJson",
368
+ "_url",
369
+ "_quoteText",
370
+ "_quoteAuthor",
371
+ "_rating",
372
+ "_showTitle",
373
+ "_showRating",
374
+ "_attachedTexts",
375
+ "_attachmentOrder",
376
+ ]);
377
+
378
+ protected updated(changed: Map<string, unknown>) {
379
+ super.updated(changed);
380
+
381
+ // Initialize editor after first render or when format changes
382
+ if (!this._editor) {
383
+ this._initEditor();
384
+ }
385
+
386
+ if (changed.has("format") && changed.get("format") !== undefined) {
387
+ // Format changed — recreate editor with appropriate placeholder
388
+ this._destroyEditor();
389
+ // Schedule init after Lit re-renders the new template
390
+ this.updateComplete.then(() => this._initEditor());
391
+ }
392
+
393
+ if (
394
+ changed.has("_attachmentOrder") ||
395
+ changed.has("_attachments") ||
396
+ changed.has("_attachedTexts")
397
+ ) {
398
+ if (this._attachmentOrder.length > 1) {
399
+ this.#initSortable();
400
+ } else {
401
+ this.#sortable?.destroy();
402
+ this.#sortable = null;
403
+ }
404
+ }
405
+
406
+ // Notify parent dialog of content changes for draft auto-save
407
+ for (const key of changed.keys()) {
408
+ if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
409
+ this.dispatchEvent(
410
+ new Event("jant:compose-content-changed", { bubbles: true }),
411
+ );
412
+ break;
413
+ }
414
+ }
415
+ }
416
+
417
+ /** Returns Tiptap editor content and title for fullscreen handoff */
418
+ getEditorState() {
419
+ return {
420
+ json: this._editor?.getJSON() ?? this._bodyJson,
421
+ title: this._title,
422
+ showTitle: this._showTitle,
423
+ };
424
+ }
425
+
426
+ /** Pre-fill all fields for edit mode or draft restore */
427
+ populate(data: {
428
+ format: string;
429
+ title?: string;
430
+ bodyJson?: string;
431
+ url?: string;
432
+ quoteText?: string;
433
+ quoteAuthor?: string;
434
+ rating?: number;
435
+ showTitle?: boolean;
436
+ showRating?: boolean;
437
+ media?: Array<{
438
+ id: string;
439
+ previewUrl: string;
440
+ alt?: string;
441
+ mimeType: string;
442
+ originalName?: string;
443
+ summary?: string;
444
+ chars?: number;
445
+ }>;
446
+ textAttachments?: Array<{
447
+ clientId?: string;
448
+ bodyJson: string;
449
+ bodyHtml?: string;
450
+ summary: string;
451
+ mediaId?: string;
452
+ }>;
453
+ attachmentOrder?: string[];
454
+ }) {
455
+ if (data.title) this._title = data.title;
456
+ if (data.url) this._url = data.url;
457
+ if (data.quoteText) this._quoteText = data.quoteText;
458
+ if (data.quoteAuthor) this._quoteAuthor = data.quoteAuthor;
459
+ if (data.rating && data.rating > 0) {
460
+ this._rating = data.rating;
461
+ this._showRating = true;
462
+ }
463
+ if (data.showTitle !== undefined) this._showTitle = data.showTitle;
464
+ else if (data.title && data.format === "note") this._showTitle = true;
465
+ if (data.showRating !== undefined) this._showRating = data.showRating;
466
+
467
+ // Parse body JSON and set editor content
468
+ if (data.bodyJson) {
469
+ try {
470
+ const parsed = JSON.parse(data.bodyJson) as JSONContent;
471
+ this._bodyJson = parsed;
472
+ if (this._editor) {
473
+ this._editor.commands.setContent(parsed);
474
+ }
475
+ } catch {
476
+ // Body is not valid JSON — ignore
477
+ }
478
+ }
479
+
480
+ // Convert media attachments to ComposeAttachment[] with status "done"
481
+ if (data.media?.length) {
482
+ const attachments = data.media.map((m) => ({
483
+ clientId: crypto.randomUUID(),
484
+ file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
485
+ previewUrl: m.previewUrl,
486
+ status: "done" as const,
487
+ progress: null,
488
+ mediaId: m.id,
489
+ alt: m.alt ?? "",
490
+ error: null,
491
+ summary: m.summary ?? null,
492
+ chars: m.chars ?? null,
493
+ }));
494
+ this._attachments = attachments;
495
+ this._attachmentOrder = attachments.map((a) => a.clientId);
496
+ }
497
+
498
+ // Restore attached texts from server data
499
+ if (data.textAttachments?.length) {
500
+ const texts: AttachedTextItem[] = data.textAttachments.map((t) => {
501
+ let parsed: JSONContent | null = null;
502
+ try {
503
+ parsed = JSON.parse(t.bodyJson) as JSONContent;
504
+ } catch {
505
+ // Invalid JSON — leave as null
506
+ }
507
+ return {
508
+ clientId: t.clientId ?? crypto.randomUUID(),
509
+ bodyJson: parsed,
510
+ bodyHtml: t.bodyHtml ?? "",
511
+ summary: t.summary,
512
+ mediaId: t.mediaId,
513
+ };
514
+ });
515
+ this._attachedTexts = texts;
516
+ this._attachmentOrder = [
517
+ ...this._attachmentOrder,
518
+ ...texts.map((t) => t.clientId),
519
+ ];
520
+ }
521
+
522
+ if (data.attachmentOrder?.length) {
523
+ const orderedClientIds = data.attachmentOrder
524
+ .map((attachmentId) => {
525
+ const mediaClientId = this._attachments.find(
526
+ (item) =>
527
+ item.mediaId === attachmentId || item.clientId === attachmentId,
528
+ )?.clientId;
529
+ if (mediaClientId) return mediaClientId;
530
+ return this._attachedTexts.find(
531
+ (item) =>
532
+ item.mediaId === attachmentId || item.clientId === attachmentId,
533
+ )?.clientId;
534
+ })
535
+ .filter((clientId): clientId is string => clientId !== undefined);
536
+
537
+ const remainingClientIds = this._attachmentOrder.filter(
538
+ (clientId) => !orderedClientIds.includes(clientId),
539
+ );
540
+ this._attachmentOrder = [...orderedClientIds, ...remainingClientIds];
541
+ }
542
+ }
543
+
544
+ /** Updates editor content and title from fullscreen close */
545
+ setEditorState(json: JSONContent | null, title: string) {
546
+ this._bodyJson = json;
547
+ this._title = title;
548
+ // Show the title field if user typed a title in fullscreen
549
+ if (title && this.format === "note") {
550
+ this._showTitle = true;
551
+ }
552
+ if (this._editor && json) {
553
+ this._editor.commands.setContent(json);
554
+ }
555
+ }
556
+
557
+ private static SUMMARY_LENGTH = 100;
558
+
559
+ private _computeSummary(text: string): string {
560
+ const plain = text.replace(/\s+/g, " ").trim();
561
+ if (plain.length <= JantComposeEditor.SUMMARY_LENGTH) return plain;
562
+ return plain.slice(0, JantComposeEditor.SUMMARY_LENGTH) + "…";
563
+ }
564
+
565
+ private _openAttachedText() {
566
+ const item: AttachedTextItem = {
567
+ clientId: crypto.randomUUID(),
568
+ bodyJson: null,
569
+ bodyHtml: "",
570
+ summary: "",
571
+ };
572
+ this._attachedTexts = [...this._attachedTexts, item];
573
+ this._attachmentOrder = [...this._attachmentOrder, item.clientId];
574
+ const index = this._attachedTexts.length - 1;
575
+ this.dispatchEvent(
576
+ new CustomEvent("jant:attached-panel-open", {
577
+ bubbles: true,
578
+ detail: { index },
579
+ }),
580
+ );
581
+ }
582
+
583
+ private _moveAttachment(clientId: string, direction: -1 | 1) {
584
+ const index = this._attachmentOrder.indexOf(clientId);
585
+ const nextIndex = index + direction;
586
+ if (
587
+ index === -1 ||
588
+ nextIndex < 0 ||
589
+ nextIndex >= this._attachmentOrder.length
590
+ ) {
591
+ return;
592
+ }
593
+
594
+ const nextOrder = [...this._attachmentOrder];
595
+ const [item] = nextOrder.splice(index, 1);
596
+ if (!item) return;
597
+ nextOrder.splice(nextIndex, 0, item);
598
+ this._attachmentOrder = nextOrder;
599
+ this.#scrollAttachmentIntoView(clientId);
600
+ }
601
+
602
+ private _handleAttachmentKeydown(
603
+ clientId: string,
604
+ e: globalThis.KeyboardEvent,
605
+ onActivate?: () => void,
606
+ ) {
607
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
608
+ e.preventDefault();
609
+ this._moveAttachment(clientId, -1);
610
+ return;
611
+ }
612
+
613
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
614
+ e.preventDefault();
615
+ this._moveAttachment(clientId, 1);
616
+ return;
617
+ }
618
+
619
+ if (onActivate && (e.key === "Enter" || e.key === " ")) {
620
+ e.preventDefault();
621
+ onActivate();
622
+ }
623
+ }
624
+
625
+ #initSortable() {
626
+ const list = this.querySelector<HTMLElement>("[data-attachment-list]");
627
+ if (!list || this.#sortable || this._attachmentOrder.length <= 1) return;
628
+
629
+ this.#sortable = Sortable.create(list, {
630
+ animation: 180,
631
+ bubbleScroll: false,
632
+ chosenClass: "compose-attachment-chosen",
633
+ direction: "horizontal",
634
+ dragClass: "compose-attachment-drag",
635
+ fallbackTolerance: 4,
636
+ filter:
637
+ "button, a, input, textarea, select, option, [contenteditable='true']",
638
+ forceAutoScrollFallback: true,
639
+ ghostClass: "compose-attachment-ghost",
640
+ handle: "[data-attachment-sortable]",
641
+ preventOnFilter: false,
642
+ scroll: list,
643
+ scrollSensitivity: 56,
644
+ scrollSpeed: 18,
645
+ onChoose: () => {
646
+ list.dataset.dragging = "true";
647
+ },
648
+ onStart: (evt) => {
649
+ this.#revertNextSibling = evt.item.nextSibling;
650
+ },
651
+ onUnchoose: () => {
652
+ delete list.dataset.dragging;
653
+ },
654
+ onEnd: (evt) => {
655
+ const els = [
656
+ ...list.querySelectorAll<HTMLElement>("[data-attachment-id]"),
657
+ ];
658
+ const orderedIds = els
659
+ .map((el) => el.dataset.attachmentId)
660
+ .filter((id): id is string => id !== undefined);
661
+
662
+ const { item, oldIndex, newIndex } = evt;
663
+ if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
664
+ item.parentNode?.removeChild(item);
665
+ if (this.#revertNextSibling) {
666
+ list.insertBefore(item, this.#revertNextSibling);
667
+ } else {
668
+ list.appendChild(item);
669
+ }
670
+ }
671
+ this.#revertNextSibling = null;
672
+ delete list.dataset.dragging;
673
+
674
+ this.#sortable?.destroy();
675
+ this.#sortable = null;
676
+
677
+ if (orderedIds.length === this._attachmentOrder.length) {
678
+ this._attachmentOrder = orderedIds;
679
+ const movedId =
680
+ evt.newIndex != null ? orderedIds[evt.newIndex] : undefined;
681
+ if (movedId) {
682
+ this._suppressAttachedTextOpenUntil = Date.now() + 250;
683
+ this.#scrollAttachmentIntoView(movedId);
684
+ }
685
+ }
686
+ },
687
+ });
688
+ }
689
+
690
+ #scrollAttachmentIntoView(clientId: string) {
691
+ void this.updateComplete.then(() => {
692
+ const target = this.querySelector<HTMLElement>(
693
+ `[data-attachment-id="${clientId}"]`,
694
+ );
695
+ target?.scrollIntoView({
696
+ behavior: "smooth",
697
+ block: "nearest",
698
+ inline: "nearest",
699
+ });
700
+ });
701
+ }
702
+
703
+ private _maybeEditAttachedText(index: number) {
704
+ if (Date.now() < this._suppressAttachedTextOpenUntil) {
705
+ return;
706
+ }
707
+ this._editAttachedText(index);
708
+ }
709
+
710
+ private _editAttachedText(index: number) {
711
+ this.dispatchEvent(
712
+ new CustomEvent("jant:attached-panel-open", {
713
+ bubbles: true,
714
+ detail: { index },
715
+ }),
716
+ );
717
+ }
718
+
719
+ private _removeAttachedText(index: number) {
720
+ const removed = this._attachedTexts[index];
721
+ this._attachedTexts = this._attachedTexts.filter((_, i) => i !== index);
722
+ if (removed) {
723
+ this._attachmentOrder = this._attachmentOrder.filter(
724
+ (id) => id !== removed.clientId,
725
+ );
726
+ }
727
+ }
728
+
729
+ updateAttachedText(
730
+ index: number,
731
+ bodyJson: JSONContent | null,
732
+ bodyHtml?: string,
733
+ ) {
734
+ const plainText = this._extractPlainText(bodyJson);
735
+ this._attachedTexts = this._attachedTexts.map((item, i) =>
736
+ i === index
737
+ ? {
738
+ ...item,
739
+ bodyJson,
740
+ bodyHtml: bodyHtml ?? "",
741
+ summary: this._computeSummary(plainText),
742
+ }
743
+ : item,
744
+ );
745
+ }
746
+
747
+ closeAttachedPanel(index: number) {
748
+ const item = this._attachedTexts[index];
749
+ if (item && !this._hasAttachedTextContent(item.bodyJson)) {
750
+ this._attachedTexts = this._attachedTexts.filter((_, i) => i !== index);
751
+ this._attachmentOrder = this._attachmentOrder.filter(
752
+ (id) => id !== item.clientId,
753
+ );
754
+ }
755
+ }
756
+
757
+ private _hasAttachedTextContent(bodyJson: JSONContent | null): boolean {
758
+ if (!bodyJson) return false;
759
+ return this._extractPlainText(bodyJson).trim().length > 0;
760
+ }
761
+
762
+ private _extractPlainText(json: JSONContent | null): string {
763
+ if (!json) return "";
764
+ let text = "";
765
+ const walk = (node: JSONContent) => {
766
+ if (node.text) text += node.text;
767
+ if (node.content) node.content.forEach(walk);
768
+ };
769
+ walk(json);
770
+ return text;
771
+ }
772
+
773
+ private _onInput(field: string, e: Event) {
774
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement;
775
+ (this as Record<string, unknown>)[field] = target.value;
776
+ if (target.tagName === "TEXTAREA") {
777
+ this._autoResize(target as HTMLElement);
778
+ }
779
+ }
780
+
781
+ private _autoResize(el: HTMLElement) {
782
+ el.style.height = "auto";
783
+ el.style.height = `${el.scrollHeight}px`;
784
+ }
785
+
786
+ private _setRating(star: number) {
787
+ this._rating = this._rating === star ? 0 : star;
788
+ }
789
+
790
+ private _openFilePicker() {
791
+ if (!this._fileInput) {
792
+ this._fileInput = document.createElement("input");
793
+ this._fileInput.type = "file";
794
+ this._fileInput.accept = UPLOAD_ACCEPT;
795
+ this._fileInput.multiple = true;
796
+ this._fileInput.style.display = "none";
797
+ this._fileInput.addEventListener("change", () =>
798
+ this._handleFilesSelected(),
799
+ );
800
+ this.appendChild(this._fileInput);
801
+ }
802
+ this._fileInput.value = "";
803
+ this._fileInput.click();
804
+ }
805
+
806
+ private _handleFilesSelected() {
807
+ if (!this._fileInput?.files?.length) return;
808
+
809
+ const newAttachments: ComposeAttachment[] = [];
810
+ const files: { file: File; clientId: string }[] = [];
811
+
812
+ for (const file of Array.from(this._fileInput.files)) {
813
+ // Validate before creating attachment preview
814
+ const error = validateUploadFile(file, {
815
+ maxFileSizeMB: this.uploadMaxFileSize,
816
+ });
817
+ if (error) {
818
+ showToast(error, "error");
819
+ continue;
820
+ }
821
+
822
+ const clientId = crypto.randomUUID();
823
+ const previewUrl = URL.createObjectURL(file);
824
+ newAttachments.push({
825
+ clientId,
826
+ file,
827
+ previewUrl,
828
+ status: "pending",
829
+ progress: null,
830
+ mediaId: null,
831
+ alt: "",
832
+ error: null,
833
+ summary: null,
834
+ chars: null,
835
+ });
836
+ files.push({ file, clientId });
837
+ }
838
+
839
+ if (newAttachments.length === 0) return;
840
+
841
+ this._attachments = [...this._attachments, ...newAttachments];
842
+ this._attachmentOrder = [
843
+ ...this._attachmentOrder,
844
+ ...newAttachments.map((a) => a.clientId),
845
+ ];
846
+
847
+ // Extract summaries and char counts for text-category files asynchronously
848
+ for (const att of newAttachments) {
849
+ const category = getMediaCategory(att.file.type);
850
+ if (category === "text") {
851
+ att.file.text().then((content) => {
852
+ const summary = this._computeSummary(content);
853
+ const chars = content.length;
854
+ this._attachments = this._attachments.map((a) =>
855
+ a.clientId === att.clientId ? { ...a, summary, chars } : a,
856
+ );
857
+ });
858
+ }
859
+ }
860
+
861
+ this.dispatchEvent(
862
+ new CustomEvent("jant:files-selected", {
863
+ bubbles: true,
864
+ detail: { files },
865
+ }),
866
+ );
867
+ }
868
+
869
+ removeAttachment(clientId: string) {
870
+ const index = this._attachments.findIndex((a) => a.clientId === clientId);
871
+ if (index !== -1) this._removeAttachment(index);
872
+ }
873
+
874
+ private _removeAttachment(index: number) {
875
+ const attachment = this._attachments[index];
876
+ if (attachment) {
877
+ URL.revokeObjectURL(attachment.previewUrl);
878
+ this.dispatchEvent(
879
+ new CustomEvent("jant:attachment-removed", {
880
+ bubbles: true,
881
+ detail: {
882
+ clientId: attachment.clientId,
883
+ mediaId: attachment.mediaId,
884
+ },
885
+ }),
886
+ );
887
+ }
888
+ if (attachment) {
889
+ this._attachmentOrder = this._attachmentOrder.filter(
890
+ (id) => id !== attachment.clientId,
891
+ );
892
+ }
893
+ this._attachments = this._attachments.filter((_, i) => i !== index);
894
+ // Close alt panel if it was showing the removed item
895
+ if (this._showAltPanel && this._altPanelIndex === index) {
896
+ this._showAltPanel = false;
897
+ this.dispatchEvent(
898
+ new CustomEvent("jant:alt-panel-close", { bubbles: true }),
899
+ );
900
+ } else if (this._showAltPanel && this._altPanelIndex > index) {
901
+ this._altPanelIndex = this._altPanelIndex - 1;
902
+ }
903
+ }
904
+
905
+ private _retryAllFailed() {
906
+ const failed = this._attachments.filter((a) => a.status === "error");
907
+ if (failed.length === 0) return;
908
+
909
+ // Reset failed attachments to pending
910
+ this._attachments = this._attachments.map((a) =>
911
+ a.status === "error"
912
+ ? { ...a, status: "pending" as const, progress: null, error: null }
913
+ : a,
914
+ );
915
+
916
+ // Re-dispatch them through the normal upload flow
917
+ this.dispatchEvent(
918
+ new CustomEvent("jant:files-selected", {
919
+ bubbles: true,
920
+ detail: {
921
+ files: failed.map((a) => ({ file: a.file, clientId: a.clientId })),
922
+ },
923
+ }),
924
+ );
925
+ }
926
+
927
+ private _openAltPanel(index: number) {
928
+ this._altPanelIndex = index;
929
+ this._showAltPanel = true;
930
+ this.dispatchEvent(
931
+ new CustomEvent("jant:alt-panel-open", {
932
+ bubbles: true,
933
+ detail: { index },
934
+ }),
935
+ );
936
+ }
937
+
938
+ updateAlt(index: number, value: string) {
939
+ this._attachments = this._attachments.map((a, i) =>
940
+ i === index ? { ...a, alt: value } : a,
941
+ );
942
+ }
943
+
944
+ // ── Emoji picker ────────────────────────────────────────────────
945
+
946
+ private _onFieldFocus(e: Event) {
947
+ const target = e.target as HTMLTextAreaElement | HTMLInputElement;
948
+ this._lastFocusedField = target;
949
+ }
950
+
951
+ private _toggleEmojiPicker() {
952
+ if (this._showEmojiPicker) {
953
+ this.closeEmojiPicker();
954
+ } else {
955
+ this._showEmojiPicker = true;
956
+ this._mountEmojiPicker();
957
+ // Defer listener so the current click event doesn't immediately close it
958
+ globalThis.setTimeout(() => {
959
+ document.addEventListener("click", this._onDocClickBound);
960
+ }, 0);
961
+ }
962
+ }
963
+
964
+ closeEmojiPicker() {
965
+ if (!this._showEmojiPicker) return;
966
+ this._showEmojiPicker = false;
967
+ this._emojiContainer?.remove();
968
+ document.removeEventListener("click", this._onDocClickBound);
969
+ }
970
+
971
+ private _onDocumentClick(e: Event) {
972
+ const target = e.target as globalThis.Node;
973
+ const btn = this.querySelector(".compose-emoji-btn");
974
+ if (btn?.contains(target)) return;
975
+ if (this._emojiContainer?.contains(target)) return;
976
+ this.closeEmojiPicker();
977
+ }
978
+
979
+ private async _mountEmojiPicker() {
980
+ // Portal into the <dialog> element (shares top-layer, escapes inner overflow/transform)
981
+ const dialog = this.closest("dialog");
982
+ if (!this._emojiContainer) {
983
+ this._emojiContainer = document.createElement("div");
984
+ this._emojiContainer.className = "compose-emoji-picker";
985
+ }
986
+ (dialog ?? document.body).appendChild(this._emojiContainer);
987
+
988
+ // Only create the picker element once
989
+ if (!this._emojiPickerEl) {
990
+ const [{ default: data }, { Picker }] = await Promise.all([
991
+ import("@emoji-mart/data"),
992
+ import("emoji-mart"),
993
+ ]);
994
+
995
+ // Check we're still open after the async import
996
+ if (!this._showEmojiPicker) return;
997
+
998
+ const picker = new Picker({
999
+ data,
1000
+ onEmojiSelect: (emoji: { native: string }) => {
1001
+ this._insertEmoji(emoji.native);
1002
+ this.closeEmojiPicker();
1003
+ },
1004
+ theme: "auto",
1005
+ previewPosition: "none",
1006
+ skinTonePosition: "none",
1007
+ });
1008
+ this._emojiPickerEl = picker as unknown as HTMLElement;
1009
+ }
1010
+
1011
+ this._emojiContainer.innerHTML = "";
1012
+ this._emojiContainer.appendChild(this._emojiPickerEl);
1013
+
1014
+ // Position relative to the dialog (whose transform makes fixed = absolute)
1015
+ const btn = this.querySelector(".compose-emoji-btn");
1016
+ if (btn && dialog) {
1017
+ const btnRect = btn.getBoundingClientRect();
1018
+ const dlgRect = dialog.getBoundingClientRect();
1019
+ const pickerWidth = 352;
1020
+ const pickerHeight = 435;
1021
+
1022
+ // Button position relative to the dialog
1023
+ const btnRelLeft = btnRect.left - dlgRect.left;
1024
+ const btnRelTop = btnRect.top - dlgRect.top;
1025
+
1026
+ let left = btnRelLeft + btnRect.width / 2 - pickerWidth / 2;
1027
+ left = Math.max(-dlgRect.left + 8, Math.min(left, dlgRect.width - 8));
1028
+
1029
+ let top = btnRelTop - pickerHeight - 8;
1030
+ if (dlgRect.top + top < 8) {
1031
+ top = btnRelTop + btnRect.height + 8;
1032
+ }
1033
+
1034
+ this._emojiContainer.style.left = `${left}px`;
1035
+ this._emojiContainer.style.top = `${top}px`;
1036
+ }
1037
+ }
1038
+
1039
+ private _insertEmoji(emoji: string) {
1040
+ const field = this._lastFocusedField;
1041
+ if (!field) {
1042
+ // Insert into Tiptap editor
1043
+ if (this._editor) {
1044
+ this._editor.chain().focus().insertContent(emoji).run();
1045
+ }
1046
+ return;
1047
+ }
1048
+
1049
+ const start = field.selectionStart ?? field.value.length;
1050
+ const end = field.selectionEnd ?? start;
1051
+ const before = field.value.slice(0, start);
1052
+ const after = field.value.slice(end);
1053
+ const newValue = before + emoji + after;
1054
+
1055
+ // Update the Lit state that corresponds to this field
1056
+ field.value = newValue;
1057
+ field.dispatchEvent(new Event("input", { bubbles: true }));
1058
+
1059
+ // Restore cursor position after the inserted emoji
1060
+ const cursorPos = start + emoji.length;
1061
+ globalThis.requestAnimationFrame(() => {
1062
+ field.focus();
1063
+ field.setSelectionRange(cursorPos, cursorPos);
1064
+ });
1065
+ }
1066
+
1067
+ // ── Helpers ──────────────────────────────────────────────────────
1068
+
1069
+ private _getCategory(a: ComposeAttachment): MediaCategory {
1070
+ return getMediaCategory(a.file.type);
1071
+ }
1072
+
1073
+ private _formatSize(bytes: number): string {
1074
+ if (bytes < 1024) return `${bytes} B`;
1075
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1076
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1077
+ }
1078
+
1079
+ private _formatChars(count: number): string {
1080
+ if (count < 1000) return `${count} chars`;
1081
+ if (count < 1_000_000) {
1082
+ return `${parseFloat((count / 1000).toFixed(1))}k chars`;
1083
+ }
1084
+ return `${parseFloat((count / 1_000_000).toFixed(1))}M chars`;
1085
+ }
1086
+
1087
+ private _renderFileIcon(mimeType: string, size: number) {
1088
+ const doc = `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`;
1089
+
1090
+ let inner: string;
1091
+ if (mimeType === "application/pdf") {
1092
+ inner = `<text x="12" y="16.5" text-anchor="middle" fill="currentColor" stroke="none" font-size="6" font-weight="700" font-family="system-ui, sans-serif">PDF</text>`;
1093
+ } else if (mimeType === "text/markdown") {
1094
+ inner = `<text x="12" y="16.5" text-anchor="middle" fill="currentColor" stroke="none" font-size="10" font-weight="700" font-family="system-ui, sans-serif">#</text>`;
1095
+ } else if (mimeType === "text/csv") {
1096
+ inner = `<line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="15" x2="16" y2="15"/><line x1="8" y1="18" x2="16" y2="18"/><line x1="10.7" y1="12" x2="10.7" y2="18"/><line x1="13.3" y1="12" x2="13.3" y2="18"/>`;
1097
+ } else if (getMediaCategory(mimeType) === "archive") {
1098
+ inner = `<line x1="12" y1="10" x2="12" y2="11.5"/><line x1="12" y1="13" x2="12" y2="14.5"/><line x1="12" y1="16" x2="12" y2="17.5"/>`;
1099
+ } else if (mimeType === "text/x-tiptap+json") {
1100
+ inner = `<line x1="16" y1="11" x2="8" y2="11"/><line x1="16" y1="14" x2="8" y2="14"/><line x1="12" y1="17" x2="8" y2="17"/>`;
1101
+ } else {
1102
+ // Plain text default — 3 text lines
1103
+ inner = `<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>`;
1104
+ }
1105
+
1106
+ return html`<svg
1107
+ width="${size}"
1108
+ height="${size}"
1109
+ viewBox="0 0 24 24"
1110
+ fill="none"
1111
+ stroke="currentColor"
1112
+ stroke-width="1.5"
1113
+ stroke-linecap="round"
1114
+ stroke-linejoin="round"
1115
+ >
1116
+ ${unsafeSVG(doc + inner)}
1117
+ </svg>`;
1118
+ }
1119
+
1120
+ // ── Render helpers ────────────────────────────────────────────────
1121
+
1122
+ private _renderNoteFields() {
1123
+ return html`
1124
+ <div class="compose-field-enter">
1125
+ ${this._showTitle
1126
+ ? html`
1127
+ <div class="compose-note-title-row">
1128
+ <input
1129
+ type="text"
1130
+ .value=${this._title}
1131
+ @input=${(e: Event) => this._onInput("_title", e)}
1132
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1133
+ @keydown=${(e: globalThis.KeyboardEvent) => {
1134
+ if (e.key === "Enter") {
1135
+ e.preventDefault();
1136
+ this._editor?.commands.focus("start");
1137
+ }
1138
+ }}
1139
+ class="compose-input compose-note-title"
1140
+ placeholder=${this.labels.titlePlaceholder}
1141
+ />
1142
+ <button
1143
+ type="button"
1144
+ class="compose-note-title-dismiss"
1145
+ @click=${() => {
1146
+ this._showTitle = false;
1147
+ }}
1148
+ >
1149
+
1150
+ </button>
1151
+ </div>
1152
+ `
1153
+ : nothing}
1154
+ <div class="compose-tiptap-body"></div>
1155
+ </div>
1156
+ `;
1157
+ }
1158
+
1159
+ private _renderLinkFields() {
1160
+ return html`
1161
+ <div class="compose-field-enter">
1162
+ <div class="compose-link-url-wrap">
1163
+ <span class="text-base opacity-50 shrink-0">🔗</span>
1164
+ <input
1165
+ type="url"
1166
+ .value=${this._url}
1167
+ @input=${(e: Event) => this._onInput("_url", e)}
1168
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1169
+ class="compose-input text-[0.9rem]"
1170
+ placeholder=${this.labels.urlPlaceholder}
1171
+ />
1172
+ </div>
1173
+ <input
1174
+ type="text"
1175
+ .value=${this._title}
1176
+ @input=${(e: Event) => this._onInput("_title", e)}
1177
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1178
+ class="compose-input compose-link-title"
1179
+ placeholder=${this.labels.linkTitlePlaceholder}
1180
+ />
1181
+ <div class="compose-divider"></div>
1182
+ <div class="compose-tiptap-body compose-tiptap-thoughts"></div>
1183
+ </div>
1184
+ `;
1185
+ }
1186
+
1187
+ private _renderQuoteFields() {
1188
+ return html`
1189
+ <div class="compose-field-enter">
1190
+ <div class="compose-quote-wrap">
1191
+ <span class="compose-quote-mark">"</span>
1192
+ <textarea
1193
+ .value=${this._quoteText}
1194
+ @input=${(e: Event) => this._onInput("_quoteText", e)}
1195
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1196
+ class="compose-input compose-quote-text"
1197
+ placeholder=${this.labels.quotePlaceholder}
1198
+ rows="3"
1199
+ ></textarea>
1200
+ </div>
1201
+ <div class="compose-quote-author-row">
1202
+ <span class="compose-quote-dash">—</span>
1203
+ <input
1204
+ type="text"
1205
+ .value=${this._quoteAuthor}
1206
+ @input=${(e: Event) => this._onInput("_quoteAuthor", e)}
1207
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1208
+ class="compose-input compose-quote-author"
1209
+ placeholder=${this.labels.authorPlaceholder}
1210
+ />
1211
+ </div>
1212
+ <div class="compose-quote-source">
1213
+ <input
1214
+ type="url"
1215
+ .value=${this._url}
1216
+ @input=${(e: Event) => this._onInput("_url", e)}
1217
+ @focus=${(e: Event) => this._onFieldFocus(e)}
1218
+ class="compose-input text-[0.78rem]"
1219
+ placeholder=${this.labels.sourcePlaceholder}
1220
+ />
1221
+ </div>
1222
+ <div class="compose-divider"></div>
1223
+ <div class="compose-tiptap-body compose-tiptap-thoughts"></div>
1224
+ </div>
1225
+ `;
1226
+ }
1227
+
1228
+ private _renderStarRating() {
1229
+ if (!this._showRating) return nothing;
1230
+ const stars = [1, 2, 3, 4, 5];
1231
+ return html`
1232
+ <div class="compose-star-rating">
1233
+ ${stars.map(
1234
+ (n) => html`
1235
+ <button
1236
+ type="button"
1237
+ class=${classMap({
1238
+ "compose-star": true,
1239
+ "compose-star-filled": this._rating >= n,
1240
+ })}
1241
+ @click=${() => this._setRating(n)}
1242
+ >
1243
+
1244
+ </button>
1245
+ `,
1246
+ )}
1247
+ ${this._rating > 0
1248
+ ? html`<span class="compose-star-label">${this._rating}/5</span>`
1249
+ : nothing}
1250
+ </div>
1251
+ `;
1252
+ }
1253
+
1254
+ private _renderAttachmentPreview(a: ComposeAttachment) {
1255
+ const category = this._getCategory(a);
1256
+
1257
+ if (category === "video") {
1258
+ return html`
1259
+ <div class="compose-attachment-thumb">
1260
+ <video
1261
+ src=${a.previewUrl}
1262
+ class="compose-attachment-img"
1263
+ preload="metadata"
1264
+ muted
1265
+ ></video>
1266
+ <div class="compose-attachment-play-icon">
1267
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
1268
+ <path d="M8 5v14l11-7z" />
1269
+ </svg>
1270
+ </div>
1271
+ </div>
1272
+ `;
1273
+ }
1274
+
1275
+ if (category === "audio") {
1276
+ return html`
1277
+ <div class="compose-attachment-file-card">
1278
+ <div class="compose-attachment-file-icon">
1279
+ <svg
1280
+ width="20"
1281
+ height="20"
1282
+ viewBox="0 0 24 24"
1283
+ fill="none"
1284
+ stroke="currentColor"
1285
+ stroke-width="1.5"
1286
+ stroke-linecap="round"
1287
+ stroke-linejoin="round"
1288
+ >
1289
+ <path d="M9 18V5l12-2v13" />
1290
+ <circle cx="6" cy="18" r="3" />
1291
+ <circle cx="18" cy="16" r="3" />
1292
+ </svg>
1293
+ </div>
1294
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1295
+ <span class="compose-attachment-file-size"
1296
+ >${this._formatSize(a.file.size)}</span
1297
+ >
1298
+ </div>
1299
+ `;
1300
+ }
1301
+
1302
+ if (category === "document") {
1303
+ return html`
1304
+ <div class="compose-attachment-file-card">
1305
+ <div class="compose-attachment-file-icon">
1306
+ ${this._renderFileIcon(a.file.type, 20)}
1307
+ </div>
1308
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1309
+ <span class="compose-attachment-file-size"
1310
+ >${this._formatSize(a.file.size)}</span
1311
+ >
1312
+ </div>
1313
+ `;
1314
+ }
1315
+
1316
+ if (category === "text") {
1317
+ return html`
1318
+ <div class="compose-attachment-file-card">
1319
+ <div class="compose-attachment-file-icon">
1320
+ ${this._renderFileIcon(a.file.type, 20)}
1321
+ </div>
1322
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1323
+ ${a.summary
1324
+ ? html`<span class="compose-attachment-text-summary"
1325
+ >${a.summary}</span
1326
+ >`
1327
+ : nothing}
1328
+ ${typeof a.chars === "number" && a.chars > 0
1329
+ ? html`<span class="compose-attachment-file-size"
1330
+ >${this._formatChars(a.chars)}</span
1331
+ >`
1332
+ : nothing}
1333
+ </div>
1334
+ `;
1335
+ }
1336
+
1337
+ // Default for non-visual types: generic file card (archive, office, font, 3d, code, etc.)
1338
+ if (category !== "image") {
1339
+ return html`
1340
+ <div class="compose-attachment-file-card">
1341
+ <div class="compose-attachment-file-icon">
1342
+ ${this._renderFileIcon(a.file.type, 20)}
1343
+ </div>
1344
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1345
+ <span class="compose-attachment-file-size"
1346
+ >${this._formatSize(a.file.size)}</span
1347
+ >
1348
+ </div>
1349
+ `;
1350
+ }
1351
+
1352
+ // Image
1353
+ return html`
1354
+ <div class="compose-attachment-thumb">
1355
+ <img src=${a.previewUrl} alt="" class="compose-attachment-img" />
1356
+ </div>
1357
+ `;
1358
+ }
1359
+
1360
+ private _renderAttachmentOverlay(a: ComposeAttachment, index: number) {
1361
+ return html`
1362
+ ${a.status === "error"
1363
+ ? html`
1364
+ <button
1365
+ type="button"
1366
+ class="compose-attachment-overlay compose-attachment-retry"
1367
+ @click=${(e: Event) => {
1368
+ e.stopPropagation();
1369
+ this._retryAllFailed();
1370
+ }}
1371
+ >
1372
+ <span class="compose-retry-content">
1373
+ <svg
1374
+ width="20"
1375
+ height="20"
1376
+ viewBox="0 0 24 24"
1377
+ fill="none"
1378
+ stroke="currentColor"
1379
+ stroke-width="2"
1380
+ stroke-linecap="round"
1381
+ stroke-linejoin="round"
1382
+ >
1383
+ <path
1384
+ d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
1385
+ />
1386
+ <path d="M3 3v5h5" />
1387
+ <path
1388
+ d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
1389
+ />
1390
+ <path d="M16 16h5v5" />
1391
+ </svg>
1392
+ <span class="compose-retry-label">${this.labels.retryAll}</span>
1393
+ </span>
1394
+ </button>
1395
+ `
1396
+ : nothing}
1397
+ <button
1398
+ type="button"
1399
+ class="compose-attachment-remove"
1400
+ @click=${() => this._removeAttachment(index)}
1401
+ >
1402
+
1403
+ </button>
1404
+ `;
1405
+ }
1406
+
1407
+ private _renderAttachedTextCard(item: AttachedTextItem, index: number) {
1408
+ return html`
1409
+ <div class="compose-attachment" data-attachment-id=${item.clientId}>
1410
+ <div
1411
+ class="compose-attachment-thumb compose-attachment-sortable"
1412
+ data-attachment-sortable
1413
+ tabindex="0"
1414
+ @click=${() => this._maybeEditAttachedText(index)}
1415
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1416
+ this._handleAttachmentKeydown(item.clientId, e, () =>
1417
+ this._maybeEditAttachedText(index),
1418
+ )}
1419
+ >
1420
+ <div class="compose-attachment-text-card">
1421
+ <div class="compose-attachment-file-icon">
1422
+ ${this._renderFileIcon("text/x-tiptap+json", 20)}
1423
+ </div>
1424
+ <span class="compose-attachment-text-summary">${item.summary}</span>
1425
+ ${item.bodyJson
1426
+ ? html`<span class="compose-attachment-file-size"
1427
+ >${this._formatChars(
1428
+ this._extractPlainText(item.bodyJson).length,
1429
+ )}</span
1430
+ >`
1431
+ : nothing}
1432
+ </div>
1433
+ <button
1434
+ type="button"
1435
+ class="compose-attachment-remove"
1436
+ @click=${(e: Event) => {
1437
+ e.stopPropagation();
1438
+ this._removeAttachedText(index);
1439
+ }}
1440
+ >
1441
+
1442
+ </button>
1443
+ </div>
1444
+ </div>
1445
+ `;
1446
+ }
1447
+
1448
+ private _renderMediaAttachment(a: ComposeAttachment, i: number) {
1449
+ const category = this._getCategory(a);
1450
+ const isFileCard = category !== "image" && category !== "video";
1451
+
1452
+ return html`
1453
+ <div class="compose-attachment" data-attachment-id=${a.clientId}>
1454
+ ${isFileCard
1455
+ ? html`
1456
+ <div class="compose-attachment-thumb">
1457
+ <div
1458
+ class="compose-attachment-sortable"
1459
+ data-attachment-sortable
1460
+ tabindex="0"
1461
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1462
+ this._handleAttachmentKeydown(a.clientId, e)}
1463
+ >
1464
+ ${this._renderAttachmentPreview(a)}
1465
+ </div>
1466
+ ${this._renderAttachmentOverlay(a, i)}
1467
+ </div>
1468
+ `
1469
+ : html`
1470
+ <div class="compose-attachment-thumb">
1471
+ <div
1472
+ class="compose-attachment-sortable"
1473
+ data-attachment-sortable
1474
+ tabindex="0"
1475
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1476
+ this._handleAttachmentKeydown(a.clientId, e)}
1477
+ >
1478
+ ${category === "video"
1479
+ ? html`
1480
+ <video
1481
+ src=${a.previewUrl}
1482
+ class="compose-attachment-img"
1483
+ preload="metadata"
1484
+ muted
1485
+ ></video>
1486
+ <div class="compose-attachment-play-icon">
1487
+ <svg
1488
+ width="24"
1489
+ height="24"
1490
+ viewBox="0 0 24 24"
1491
+ fill="white"
1492
+ >
1493
+ <path d="M8 5v14l11-7z" />
1494
+ </svg>
1495
+ </div>
1496
+ `
1497
+ : a.status === "processing"
1498
+ ? html`
1499
+ <div class="compose-attachment-processing">
1500
+ <svg
1501
+ class="animate-spin size-5"
1502
+ viewBox="0 0 24 24"
1503
+ fill="none"
1504
+ stroke="currentColor"
1505
+ stroke-width="2"
1506
+ >
1507
+ <path
1508
+ d="M12 2a10 10 0 1 0 10 10"
1509
+ stroke-linecap="round"
1510
+ />
1511
+ </svg>
1512
+ </div>
1513
+ `
1514
+ : html`
1515
+ <img
1516
+ src=${a.previewUrl}
1517
+ alt=""
1518
+ class="compose-attachment-img"
1519
+ />
1520
+ `}
1521
+ </div>
1522
+ ${this._renderAttachmentOverlay(a, i)}
1523
+ </div>
1524
+ `}
1525
+ ${category === "image"
1526
+ ? html`
1527
+ <button
1528
+ type="button"
1529
+ class=${classMap({
1530
+ "compose-attachment-alt": true,
1531
+ "compose-attachment-alt-set": a.alt.length > 0,
1532
+ })}
1533
+ @click=${() => this._openAltPanel(i)}
1534
+ >
1535
+ ${a.alt.length > 0 ? "ALT" : "+ ALT"}
1536
+ </button>
1537
+ `
1538
+ : nothing}
1539
+ </div>
1540
+ `;
1541
+ }
1542
+
1543
+ private _renderAttachments() {
1544
+ if (this._attachments.length === 0 && this._attachedTexts.length === 0)
1545
+ return nothing;
1546
+
1547
+ return html`
1548
+ <div class="compose-attachments" data-attachment-list>
1549
+ ${this._attachmentOrder.map((clientId) => {
1550
+ const mediaIndex = this._attachments.findIndex(
1551
+ (a) => a.clientId === clientId,
1552
+ );
1553
+ if (mediaIndex !== -1) {
1554
+ return this._renderMediaAttachment(
1555
+ this._attachments[mediaIndex],
1556
+ mediaIndex,
1557
+ );
1558
+ }
1559
+ const textIndex = this._attachedTexts.findIndex(
1560
+ (t) => t.clientId === clientId,
1561
+ );
1562
+ if (textIndex !== -1) {
1563
+ return this._renderAttachedTextCard(
1564
+ this._attachedTexts[textIndex],
1565
+ textIndex,
1566
+ );
1567
+ }
1568
+ return nothing;
1569
+ })}
1570
+ </div>
1571
+ `;
1572
+ }
1573
+
1574
+ private _renderToolsRow() {
1575
+ const hasAttached = this._attachedTexts.length > 0;
1576
+ return html`
1577
+ <div class="compose-tools-row">
1578
+ <!-- Media / Add -->
1579
+ <button
1580
+ type="button"
1581
+ class=${classMap({
1582
+ "compose-tool-btn": true,
1583
+ "compose-tool-btn-add": this._attachments.length > 0,
1584
+ })}
1585
+ title=${this._attachments.length > 0 ? "" : this.labels.media}
1586
+ @click=${() => this._openFilePicker()}
1587
+ >
1588
+ <svg
1589
+ class="icon-fine"
1590
+ width="18"
1591
+ height="18"
1592
+ viewBox="0 0 18 18"
1593
+ fill="none"
1594
+ stroke="currentColor"
1595
+ stroke-width="1.4"
1596
+ stroke-linecap="round"
1597
+ stroke-linejoin="round"
1598
+ >
1599
+ <rect x="2" y="3" width="14" height="12" rx="2.5" />
1600
+ <circle cx="6.5" cy="7.5" r="1.5" />
1601
+ <path d="M2 13l4-4c.6-.6 1.4-.6 2 0l4 4" />
1602
+ <path d="M11 11l1.5-1.5c.6-.6 1.4-.6 2 0L16 11" />
1603
+ </svg>
1604
+ ${this._attachments.length > 0
1605
+ ? html`<span class="compose-tool-label"
1606
+ >${this.labels.addMore}</span
1607
+ >`
1608
+ : nothing}
1609
+ </button>
1610
+
1611
+ <!-- Attached Text -->
1612
+ <button
1613
+ type="button"
1614
+ class=${classMap({
1615
+ "compose-tool-btn": true,
1616
+ "compose-tool-btn-add": hasAttached,
1617
+ })}
1618
+ title=${hasAttached ? "" : this.labels.attachedText}
1619
+ @click=${() => this._openAttachedText()}
1620
+ >
1621
+ <svg
1622
+ class="icon-fine"
1623
+ width="18"
1624
+ height="18"
1625
+ viewBox="0 0 18 18"
1626
+ fill="none"
1627
+ stroke="currentColor"
1628
+ stroke-width="1.3"
1629
+ stroke-linecap="round"
1630
+ >
1631
+ <rect x="3" y="2" width="12" height="14" rx="2" />
1632
+ <line x1="6" y1="6" x2="12" y2="6" />
1633
+ <line x1="6" y1="9" x2="12" y2="9" />
1634
+ <line x1="6" y1="12" x2="9.5" y2="12" />
1635
+ </svg>
1636
+ ${hasAttached
1637
+ ? html`<span class="compose-tool-label"
1638
+ >${this.labels.addMore}</span
1639
+ >`
1640
+ : nothing}
1641
+ </button>
1642
+
1643
+ <!-- Rate -->
1644
+ <button
1645
+ type="button"
1646
+ class=${classMap({
1647
+ "compose-tool-btn": true,
1648
+ "compose-tool-btn-active": this._showRating,
1649
+ })}
1650
+ title=${this.labels.rate}
1651
+ @click=${() => {
1652
+ this._showRating = !this._showRating;
1653
+ }}
1654
+ >
1655
+ <svg
1656
+ class="icon-fine"
1657
+ width="18"
1658
+ height="18"
1659
+ viewBox="0 0 24 24"
1660
+ fill="none"
1661
+ >
1662
+ <defs>
1663
+ <clipPath id="half-left">
1664
+ <rect x="0" y="0" width="12" height="24" />
1665
+ </clipPath>
1666
+ </defs>
1667
+ <polygon
1668
+ points="12 2 14.8 9.2 22.5 9.7 16.8 14.8 18.8 22.3 12 18.2 5.2 22.3 7.2 14.8 1.5 9.7 9.2 9.2"
1669
+ fill="currentColor"
1670
+ opacity="0.45"
1671
+ clip-path="url(#half-left)"
1672
+ />
1673
+ <polygon
1674
+ points="12 2 14.8 9.2 22.5 9.7 16.8 14.8 18.8 22.3 12 18.2 5.2 22.3 7.2 14.8 1.5 9.7 9.2 9.2"
1675
+ fill="none"
1676
+ stroke="currentColor"
1677
+ stroke-width="2.4"
1678
+ stroke-linejoin="round"
1679
+ />
1680
+ </svg>
1681
+ </button>
1682
+
1683
+ <!-- Emoji -->
1684
+ <button
1685
+ type="button"
1686
+ class=${classMap({
1687
+ "compose-tool-btn": true,
1688
+ "compose-emoji-btn": true,
1689
+ "compose-tool-btn-active": this._showEmojiPicker,
1690
+ })}
1691
+ title=${this.labels.emoji}
1692
+ @click=${() => this._toggleEmojiPicker()}
1693
+ >
1694
+ <svg
1695
+ class="icon-fine"
1696
+ width="18"
1697
+ height="18"
1698
+ viewBox="0 0 18 18"
1699
+ fill="none"
1700
+ stroke="currentColor"
1701
+ stroke-width="1.4"
1702
+ stroke-linecap="round"
1703
+ stroke-linejoin="round"
1704
+ >
1705
+ <circle cx="9" cy="9" r="7" />
1706
+ <path d="M6 10.5c.5 1.2 1.5 2 3 2s2.5-.8 3-2" />
1707
+ <circle cx="6.5" cy="7" r="0.5" fill="currentColor" stroke="none" />
1708
+ <circle
1709
+ cx="11.5"
1710
+ cy="7"
1711
+ r="0.5"
1712
+ fill="currentColor"
1713
+ stroke="none"
1714
+ />
1715
+ </svg>
1716
+ </button>
1717
+
1718
+ <!-- Title toggle (Note only) -->
1719
+ ${this.format === "note"
1720
+ ? html`
1721
+ <div class="flex items-center gap-0.5">
1722
+ <div class="compose-tool-sep"></div>
1723
+ <button
1724
+ type="button"
1725
+ class=${classMap({
1726
+ "compose-tool-btn": true,
1727
+ "compose-tool-btn-active": this._showTitle,
1728
+ })}
1729
+ title=${this.labels.title}
1730
+ @click=${() => {
1731
+ const willShow = !this._showTitle;
1732
+ this._showTitle = willShow;
1733
+ if (willShow) {
1734
+ this.updateComplete.then(() => {
1735
+ this.querySelector<HTMLInputElement>(
1736
+ ".compose-note-title",
1737
+ )?.focus();
1738
+ });
1739
+ }
1740
+ }}
1741
+ >
1742
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
1743
+ <text
1744
+ x="3.5"
1745
+ y="14"
1746
+ font-family="serif"
1747
+ font-size="14"
1748
+ font-weight="400"
1749
+ fill="currentColor"
1750
+ >
1751
+ T
1752
+ </text>
1753
+ </svg>
1754
+ </button>
1755
+ </div>
1756
+ `
1757
+ : nothing}
1758
+
1759
+ <div class="flex-1"></div>
1760
+
1761
+ <!-- Expand to fullscreen -->
1762
+ <button
1763
+ type="button"
1764
+ class="compose-tool-btn"
1765
+ @click=${() => this._openFullscreen()}
1766
+ >
1767
+ <svg
1768
+ class="icon-fine"
1769
+ width="18"
1770
+ height="18"
1771
+ viewBox="0 0 18 18"
1772
+ fill="none"
1773
+ stroke="currentColor"
1774
+ stroke-width="1.4"
1775
+ stroke-linecap="round"
1776
+ stroke-linejoin="round"
1777
+ >
1778
+ <polyline points="6 2 2 2 2 6" />
1779
+ <polyline points="12 16 16 16 16 12" />
1780
+ <line x1="2" y1="2" x2="7" y2="7" />
1781
+ <line x1="16" y1="16" x2="11" y2="11" />
1782
+ </svg>
1783
+ </button>
1784
+ </div>
1785
+ `;
1786
+ }
1787
+
1788
+ private _openFullscreen() {
1789
+ const state = this.getEditorState();
1790
+ this.dispatchEvent(
1791
+ new CustomEvent("jant:fullscreen-open", {
1792
+ bubbles: true,
1793
+ detail: { ...state, labels: this.labels },
1794
+ }),
1795
+ );
1796
+ }
1797
+
1798
+ render() {
1799
+ return html`
1800
+ <section class="compose-body">
1801
+ ${this.format === "note"
1802
+ ? this._renderNoteFields()
1803
+ : this.format === "link"
1804
+ ? this._renderLinkFields()
1805
+ : this._renderQuoteFields()}
1806
+ ${this._renderAttachments()} ${this._renderStarRating()}
1807
+ </section>
1808
+ ${this._renderToolsRow()}
1809
+ `;
1810
+ }
1811
+ }
1812
+
1813
+ customElements.define("jant-compose-editor", JantComposeEditor);