@jant/core 0.3.34 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3327 -3031
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +245 -6
  93. package/src/routes/feed/rss.ts +70 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -0,0 +1,1249 @@
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 type { Editor, JSONContent } from "@tiptap/core";
14
+ import type {
15
+ ComposeFormat,
16
+ ComposeLabels,
17
+ ComposeAttachment,
18
+ } from "./compose-types.js";
19
+ import {
20
+ UPLOAD_ACCEPT,
21
+ getMediaCategory,
22
+ validateUploadFile,
23
+ } from "../../lib/upload.js";
24
+ import type { MediaCategory } from "../../lib/upload.js";
25
+ import { showToast } from "../toast.js";
26
+ import { createTiptapEditor } from "../tiptap/create-editor.js";
27
+
28
+ export class JantComposeEditor extends LitElement {
29
+ static properties = {
30
+ format: { type: String },
31
+ labels: { type: Object },
32
+ uploadMaxFileSize: { type: Number },
33
+ _title: { state: true },
34
+ _bodyJson: { state: true },
35
+ _url: { state: true },
36
+ _quoteText: { state: true },
37
+ _quoteAuthor: { state: true },
38
+ _rating: { state: true },
39
+ _showTitle: { state: true },
40
+ _showRating: { state: true },
41
+ _attachedText: { state: true },
42
+ _showAttachedText: { state: true },
43
+ _attachments: { state: true },
44
+ _showAltPanel: { state: true },
45
+ _altPanelIndex: { state: true },
46
+ _showEmojiPicker: { state: true },
47
+ };
48
+
49
+ declare format: ComposeFormat;
50
+ declare labels: ComposeLabels;
51
+ declare uploadMaxFileSize: number;
52
+ declare _title: string;
53
+ declare _bodyJson: JSONContent | null;
54
+ declare _url: string;
55
+ declare _quoteText: string;
56
+ declare _quoteAuthor: string;
57
+ declare _rating: number;
58
+ declare _showTitle: boolean;
59
+ declare _showRating: boolean;
60
+ declare _attachedText: string;
61
+ declare _showAttachedText: boolean;
62
+ declare _attachments: ComposeAttachment[];
63
+ declare _showAltPanel: boolean;
64
+ declare _altPanelIndex: number;
65
+ declare _showEmojiPicker: boolean;
66
+
67
+ private _editor: Editor | null = null;
68
+ private _fileInput: HTMLInputElement | null = null;
69
+ private _lastFocusedField: HTMLTextAreaElement | HTMLInputElement | null =
70
+ null;
71
+ private _emojiPickerEl: HTMLElement | null = null;
72
+ private _emojiContainer: HTMLElement | null = null;
73
+ private _onDocClickBound = this._onDocumentClick.bind(this);
74
+
75
+ createRenderRoot() {
76
+ return this;
77
+ }
78
+
79
+ constructor() {
80
+ super();
81
+ this.format = "note";
82
+ this.labels = {} as ComposeLabels;
83
+ this.uploadMaxFileSize = 500;
84
+ this._title = "";
85
+ this._bodyJson = null;
86
+ this._url = "";
87
+ this._quoteText = "";
88
+ this._quoteAuthor = "";
89
+ this._rating = 0;
90
+ this._showTitle = false;
91
+ this._showRating = false;
92
+ this._attachedText = "";
93
+ this._showAttachedText = false;
94
+ this._attachments = [];
95
+ this._showAltPanel = false;
96
+ this._altPanelIndex = 0;
97
+ this._showEmojiPicker = false;
98
+ }
99
+
100
+ connectedCallback() {
101
+ super.connectedCallback();
102
+ document.addEventListener("jant:slash-image", this._onSlashImage);
103
+ }
104
+
105
+ disconnectedCallback() {
106
+ super.disconnectedCallback();
107
+ this._editor?.destroy();
108
+ this._editor = null;
109
+ document.removeEventListener("jant:slash-image", this._onSlashImage);
110
+ document.removeEventListener("click", this._onDocClickBound, true);
111
+ this._emojiContainer?.remove();
112
+ }
113
+
114
+ private _onSlashImage = () => {
115
+ // Skip when fullscreen is open — it has its own handler
116
+ if (document.querySelector(".compose-fullscreen-dialog[open]")) return;
117
+ if (!this._editor) return;
118
+ this._triggerSlashImagePicker();
119
+ };
120
+
121
+ private _slashImageInput: HTMLInputElement | null = null;
122
+
123
+ private _triggerSlashImagePicker() {
124
+ if (!this._slashImageInput) {
125
+ this._slashImageInput = document.createElement("input");
126
+ this._slashImageInput.type = "file";
127
+ this._slashImageInput.accept = "image/*";
128
+ this._slashImageInput.style.display = "none";
129
+ this._slashImageInput.addEventListener("change", () => {
130
+ const file = this._slashImageInput?.files?.[0];
131
+ if (file && this._editor) {
132
+ this._uploadAndInsertImage(file);
133
+ }
134
+ if (this._slashImageInput) this._slashImageInput.value = "";
135
+ });
136
+ document.body.appendChild(this._slashImageInput);
137
+ }
138
+ this._slashImageInput.click();
139
+ }
140
+
141
+ private async _uploadAndInsertImage(file: File) {
142
+ if (!this._editor) return;
143
+
144
+ const placeholderUrl = URL.createObjectURL(file);
145
+ this._editor.chain().focus().setImage({ src: placeholderUrl }).run();
146
+
147
+ try {
148
+ const formData = new FormData();
149
+ formData.append("file", file);
150
+ const response = await fetch("/api/upload", {
151
+ method: "POST",
152
+ body: formData,
153
+ });
154
+ if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
155
+ const data = (await response.json()) as { url: string };
156
+
157
+ const { doc } = this._editor.state;
158
+ let replaced = false;
159
+ doc.descendants((node, pos) => {
160
+ if (
161
+ replaced ||
162
+ node.type.name !== "image" ||
163
+ node.attrs.src !== placeholderUrl
164
+ )
165
+ return;
166
+ this._editor
167
+ ?.chain()
168
+ .focus()
169
+ .command(({ tr }) => {
170
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: data.url });
171
+ return true;
172
+ })
173
+ .run();
174
+ replaced = true;
175
+ });
176
+ } catch {
177
+ const { doc } = this._editor.state;
178
+ doc.descendants((node, pos) => {
179
+ if (node.type.name === "image" && node.attrs.src === placeholderUrl) {
180
+ this._editor
181
+ ?.chain()
182
+ .command(({ tr }) => {
183
+ tr.delete(pos, pos + node.nodeSize);
184
+ return true;
185
+ })
186
+ .run();
187
+ }
188
+ });
189
+ } finally {
190
+ URL.revokeObjectURL(placeholderUrl);
191
+ }
192
+ }
193
+
194
+ getData() {
195
+ const body = this._bodyJson ? JSON.stringify(this._bodyJson) : "";
196
+ const shared = {
197
+ rating: this._rating,
198
+ attachedText: this._attachedText,
199
+ attachments: this._attachments,
200
+ };
201
+
202
+ switch (this.format) {
203
+ case "link":
204
+ return {
205
+ ...shared,
206
+ title: this._title,
207
+ body,
208
+ url: this._url,
209
+ quoteText: "",
210
+ quoteAuthor: "",
211
+ };
212
+ case "quote":
213
+ return {
214
+ ...shared,
215
+ title: "",
216
+ body,
217
+ url: this._url,
218
+ quoteText: this._quoteText,
219
+ quoteAuthor: this._quoteAuthor,
220
+ };
221
+ default:
222
+ return {
223
+ ...shared,
224
+ title: this._showTitle ? this._title : "",
225
+ body,
226
+ url: "",
227
+ quoteText: "",
228
+ quoteAuthor: "",
229
+ };
230
+ }
231
+ }
232
+
233
+ reset() {
234
+ this._title = "";
235
+ this._bodyJson = null;
236
+ this._editor?.commands.clearContent();
237
+ this._url = "";
238
+ this._quoteText = "";
239
+ this._quoteAuthor = "";
240
+ this._rating = 0;
241
+ this._showTitle = false;
242
+ this._showRating = false;
243
+ this._attachedText = "";
244
+ this._showAttachedText = false;
245
+ // Revoke preview URLs before clearing
246
+ for (const a of this._attachments) {
247
+ URL.revokeObjectURL(a.previewUrl);
248
+ }
249
+ this._attachments = [];
250
+ this._showAltPanel = false;
251
+ this._altPanelIndex = 0;
252
+ this.closeEmojiPicker();
253
+ }
254
+
255
+ updateAttachmentStatus(
256
+ clientId: string,
257
+ status: ComposeAttachment["status"],
258
+ mediaId: string | null,
259
+ error: string | null,
260
+ ) {
261
+ this._attachments = this._attachments.map((a) =>
262
+ a.clientId === clientId ? { ...a, status, mediaId, error } : a,
263
+ );
264
+ }
265
+
266
+ focusInput() {
267
+ if (this.format === "link") {
268
+ this.querySelector<HTMLElement>('.compose-input[type="url"]')?.focus();
269
+ } else if (this.format === "quote") {
270
+ this.querySelector<HTMLElement>(".compose-quote-text")?.focus();
271
+ } else {
272
+ this._editor?.commands.focus();
273
+ }
274
+ }
275
+
276
+ private _initEditor() {
277
+ const container = this.querySelector<HTMLElement>(".compose-tiptap-body");
278
+ if (!container || this._editor) return;
279
+
280
+ this._editor = createTiptapEditor({
281
+ element: container,
282
+ placeholder:
283
+ this.format === "note"
284
+ ? this.labels.bodyPlaceholder
285
+ : this.labels.thoughtsPlaceholder,
286
+ content: this._bodyJson,
287
+ onUpdate: (json) => {
288
+ this._bodyJson = json;
289
+ },
290
+ onFocus: () => {
291
+ this._lastFocusedField = null;
292
+ },
293
+ });
294
+ }
295
+
296
+ private _destroyEditor() {
297
+ this._editor?.destroy();
298
+ this._editor = null;
299
+ }
300
+
301
+ protected updated(changed: Map<string, unknown>) {
302
+ super.updated(changed);
303
+
304
+ // Initialize editor after first render or when format changes
305
+ if (!this._editor) {
306
+ this._initEditor();
307
+ }
308
+
309
+ if (changed.has("format") && changed.get("format") !== undefined) {
310
+ // Format changed — recreate editor with appropriate placeholder
311
+ this._destroyEditor();
312
+ // Schedule init after Lit re-renders the new template
313
+ this.updateComplete.then(() => this._initEditor());
314
+ }
315
+ }
316
+
317
+ /** Returns Tiptap editor content and title for fullscreen handoff */
318
+ getEditorState() {
319
+ return {
320
+ json: this._editor?.getJSON() ?? this._bodyJson,
321
+ title: this._title,
322
+ showTitle: this._showTitle,
323
+ };
324
+ }
325
+
326
+ /** Updates editor content and title from fullscreen close */
327
+ setEditorState(json: JSONContent | null, title: string) {
328
+ this._bodyJson = json;
329
+ this._title = title;
330
+ // Show the title field if user typed a title in fullscreen
331
+ if (title && this.format === "note") {
332
+ this._showTitle = true;
333
+ }
334
+ if (this._editor && json) {
335
+ this._editor.commands.setContent(json);
336
+ }
337
+ }
338
+
339
+ private _openAttachedText() {
340
+ this._showAttachedText = true;
341
+ this.dispatchEvent(
342
+ new CustomEvent("jant:attached-panel-open", { bubbles: true }),
343
+ );
344
+ }
345
+
346
+ updateAttachedText(value: string) {
347
+ this._attachedText = value;
348
+ }
349
+
350
+ closeAttachedPanel() {
351
+ this._showAttachedText = false;
352
+ }
353
+
354
+ private _onInput(field: string, e: Event) {
355
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement;
356
+ (this as Record<string, unknown>)[field] = target.value;
357
+ if (
358
+ target.tagName === "TEXTAREA" &&
359
+ !target.classList.contains("compose-attached-textarea")
360
+ ) {
361
+ this._autoResize(target as HTMLElement);
362
+ }
363
+ }
364
+
365
+ private _autoResize(el: HTMLElement) {
366
+ el.style.height = "auto";
367
+ el.style.height = `${el.scrollHeight}px`;
368
+ }
369
+
370
+ private _setRating(star: number) {
371
+ this._rating = this._rating === star ? 0 : star;
372
+ }
373
+
374
+ private _openFilePicker() {
375
+ if (!this._fileInput) {
376
+ this._fileInput = document.createElement("input");
377
+ this._fileInput.type = "file";
378
+ this._fileInput.accept = UPLOAD_ACCEPT;
379
+ this._fileInput.multiple = true;
380
+ this._fileInput.style.display = "none";
381
+ this._fileInput.addEventListener("change", () =>
382
+ this._handleFilesSelected(),
383
+ );
384
+ this.appendChild(this._fileInput);
385
+ }
386
+ this._fileInput.value = "";
387
+ this._fileInput.click();
388
+ }
389
+
390
+ private _handleFilesSelected() {
391
+ if (!this._fileInput?.files?.length) return;
392
+
393
+ const newAttachments: ComposeAttachment[] = [];
394
+ const files: { file: File; clientId: string }[] = [];
395
+
396
+ for (const file of Array.from(this._fileInput.files)) {
397
+ // Validate before creating attachment preview
398
+ const error = validateUploadFile(file, {
399
+ maxFileSizeMB: this.uploadMaxFileSize,
400
+ });
401
+ if (error) {
402
+ showToast(error, "error");
403
+ continue;
404
+ }
405
+
406
+ const clientId = crypto.randomUUID();
407
+ const previewUrl = URL.createObjectURL(file);
408
+ newAttachments.push({
409
+ clientId,
410
+ file,
411
+ previewUrl,
412
+ status: "pending",
413
+ mediaId: null,
414
+ alt: "",
415
+ error: null,
416
+ });
417
+ files.push({ file, clientId });
418
+ }
419
+
420
+ if (newAttachments.length === 0) return;
421
+
422
+ this._attachments = [...this._attachments, ...newAttachments];
423
+
424
+ this.dispatchEvent(
425
+ new CustomEvent("jant:files-selected", {
426
+ bubbles: true,
427
+ detail: { files },
428
+ }),
429
+ );
430
+ }
431
+
432
+ private _removeAttachment(index: number) {
433
+ const attachment = this._attachments[index];
434
+ if (attachment) {
435
+ URL.revokeObjectURL(attachment.previewUrl);
436
+ this.dispatchEvent(
437
+ new CustomEvent("jant:attachment-removed", {
438
+ bubbles: true,
439
+ detail: {
440
+ clientId: attachment.clientId,
441
+ mediaId: attachment.mediaId,
442
+ },
443
+ }),
444
+ );
445
+ }
446
+ this._attachments = this._attachments.filter((_, i) => i !== index);
447
+ // Close alt panel if it was showing the removed item
448
+ if (this._showAltPanel && this._altPanelIndex === index) {
449
+ this._showAltPanel = false;
450
+ this.dispatchEvent(
451
+ new CustomEvent("jant:alt-panel-close", { bubbles: true }),
452
+ );
453
+ } else if (this._showAltPanel && this._altPanelIndex > index) {
454
+ this._altPanelIndex = this._altPanelIndex - 1;
455
+ }
456
+ }
457
+
458
+ private _retryAllFailed() {
459
+ const failed = this._attachments.filter((a) => a.status === "error");
460
+ if (failed.length === 0) return;
461
+
462
+ // Reset failed attachments to pending
463
+ this._attachments = this._attachments.map((a) =>
464
+ a.status === "error"
465
+ ? { ...a, status: "pending" as const, error: null }
466
+ : a,
467
+ );
468
+
469
+ // Re-dispatch them through the normal upload flow
470
+ this.dispatchEvent(
471
+ new CustomEvent("jant:files-selected", {
472
+ bubbles: true,
473
+ detail: {
474
+ files: failed.map((a) => ({ file: a.file, clientId: a.clientId })),
475
+ },
476
+ }),
477
+ );
478
+ }
479
+
480
+ private _openAltPanel(index: number) {
481
+ this._altPanelIndex = index;
482
+ this._showAltPanel = true;
483
+ this.dispatchEvent(
484
+ new CustomEvent("jant:alt-panel-open", {
485
+ bubbles: true,
486
+ detail: { index },
487
+ }),
488
+ );
489
+ }
490
+
491
+ updateAlt(index: number, value: string) {
492
+ this._attachments = this._attachments.map((a, i) =>
493
+ i === index ? { ...a, alt: value } : a,
494
+ );
495
+ }
496
+
497
+ // ── Emoji picker ────────────────────────────────────────────────
498
+
499
+ private _onFieldFocus(e: Event) {
500
+ const target = e.target as HTMLTextAreaElement | HTMLInputElement;
501
+ this._lastFocusedField = target;
502
+ }
503
+
504
+ private _toggleEmojiPicker() {
505
+ if (this._showEmojiPicker) {
506
+ this.closeEmojiPicker();
507
+ } else {
508
+ this._showEmojiPicker = true;
509
+ this._mountEmojiPicker();
510
+ // Defer listener so the current click event doesn't immediately close it
511
+ globalThis.setTimeout(() => {
512
+ document.addEventListener("click", this._onDocClickBound);
513
+ }, 0);
514
+ }
515
+ }
516
+
517
+ closeEmojiPicker() {
518
+ if (!this._showEmojiPicker) return;
519
+ this._showEmojiPicker = false;
520
+ this._emojiContainer?.remove();
521
+ document.removeEventListener("click", this._onDocClickBound);
522
+ }
523
+
524
+ private _onDocumentClick(e: Event) {
525
+ const target = e.target as globalThis.Node;
526
+ const btn = this.querySelector(".compose-emoji-btn");
527
+ if (btn?.contains(target)) return;
528
+ if (this._emojiContainer?.contains(target)) return;
529
+ this.closeEmojiPicker();
530
+ }
531
+
532
+ private async _mountEmojiPicker() {
533
+ // Portal into the <dialog> element (shares top-layer, escapes inner overflow/transform)
534
+ const dialog = this.closest("dialog");
535
+ if (!this._emojiContainer) {
536
+ this._emojiContainer = document.createElement("div");
537
+ this._emojiContainer.className = "compose-emoji-picker";
538
+ }
539
+ (dialog ?? document.body).appendChild(this._emojiContainer);
540
+
541
+ // Only create the picker element once
542
+ if (!this._emojiPickerEl) {
543
+ const [{ default: data }, { Picker }] = await Promise.all([
544
+ import("@emoji-mart/data"),
545
+ import("emoji-mart"),
546
+ ]);
547
+
548
+ // Check we're still open after the async import
549
+ if (!this._showEmojiPicker) return;
550
+
551
+ const picker = new Picker({
552
+ data,
553
+ onEmojiSelect: (emoji: { native: string }) => {
554
+ this._insertEmoji(emoji.native);
555
+ this.closeEmojiPicker();
556
+ },
557
+ theme: "auto",
558
+ previewPosition: "none",
559
+ skinTonePosition: "none",
560
+ });
561
+ this._emojiPickerEl = picker as unknown as HTMLElement;
562
+ }
563
+
564
+ this._emojiContainer.innerHTML = "";
565
+ this._emojiContainer.appendChild(this._emojiPickerEl);
566
+
567
+ // Position relative to the dialog (whose transform makes fixed = absolute)
568
+ const btn = this.querySelector(".compose-emoji-btn");
569
+ if (btn && dialog) {
570
+ const btnRect = btn.getBoundingClientRect();
571
+ const dlgRect = dialog.getBoundingClientRect();
572
+ const pickerWidth = 352;
573
+ const pickerHeight = 435;
574
+
575
+ // Button position relative to the dialog
576
+ const btnRelLeft = btnRect.left - dlgRect.left;
577
+ const btnRelTop = btnRect.top - dlgRect.top;
578
+
579
+ let left = btnRelLeft + btnRect.width / 2 - pickerWidth / 2;
580
+ left = Math.max(-dlgRect.left + 8, Math.min(left, dlgRect.width - 8));
581
+
582
+ let top = btnRelTop - pickerHeight - 8;
583
+ if (dlgRect.top + top < 8) {
584
+ top = btnRelTop + btnRect.height + 8;
585
+ }
586
+
587
+ this._emojiContainer.style.left = `${left}px`;
588
+ this._emojiContainer.style.top = `${top}px`;
589
+ }
590
+ }
591
+
592
+ private _insertEmoji(emoji: string) {
593
+ const field = this._lastFocusedField;
594
+ if (!field) {
595
+ // Insert into Tiptap editor
596
+ if (this._editor) {
597
+ this._editor.chain().focus().insertContent(emoji).run();
598
+ }
599
+ return;
600
+ }
601
+
602
+ const start = field.selectionStart ?? field.value.length;
603
+ const end = field.selectionEnd ?? start;
604
+ const before = field.value.slice(0, start);
605
+ const after = field.value.slice(end);
606
+ const newValue = before + emoji + after;
607
+
608
+ // Update the Lit state that corresponds to this field
609
+ field.value = newValue;
610
+ field.dispatchEvent(new Event("input", { bubbles: true }));
611
+
612
+ // Restore cursor position after the inserted emoji
613
+ const cursorPos = start + emoji.length;
614
+ globalThis.requestAnimationFrame(() => {
615
+ field.focus();
616
+ field.setSelectionRange(cursorPos, cursorPos);
617
+ });
618
+ }
619
+
620
+ // ── Helpers ──────────────────────────────────────────────────────
621
+
622
+ private _getCategory(a: ComposeAttachment): MediaCategory | null {
623
+ return getMediaCategory(a.file.type);
624
+ }
625
+
626
+ private _formatSize(bytes: number): string {
627
+ if (bytes < 1024) return `${bytes} B`;
628
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
629
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
630
+ }
631
+
632
+ // ── Render helpers ────────────────────────────────────────────────
633
+
634
+ private _renderNoteFields() {
635
+ return html`
636
+ <div class="compose-field-enter">
637
+ ${this._showTitle
638
+ ? html`
639
+ <div class="compose-note-title-row">
640
+ <input
641
+ type="text"
642
+ .value=${this._title}
643
+ @input=${(e: Event) => this._onInput("_title", e)}
644
+ @focus=${(e: Event) => this._onFieldFocus(e)}
645
+ @keydown=${(e: globalThis.KeyboardEvent) => {
646
+ if (e.key === "Enter") {
647
+ e.preventDefault();
648
+ this._editor?.commands.focus("start");
649
+ }
650
+ }}
651
+ class="compose-input compose-note-title"
652
+ placeholder=${this.labels.titlePlaceholder}
653
+ />
654
+ <button
655
+ type="button"
656
+ class="compose-note-title-dismiss"
657
+ @click=${() => {
658
+ this._showTitle = false;
659
+ }}
660
+ >
661
+
662
+ </button>
663
+ </div>
664
+ `
665
+ : nothing}
666
+ <div class="compose-tiptap-body"></div>
667
+ </div>
668
+ `;
669
+ }
670
+
671
+ private _renderLinkFields() {
672
+ return html`
673
+ <div class="compose-field-enter">
674
+ <div class="compose-link-url-wrap">
675
+ <span class="text-base opacity-50 shrink-0">🔗</span>
676
+ <input
677
+ type="url"
678
+ .value=${this._url}
679
+ @input=${(e: Event) => this._onInput("_url", e)}
680
+ @focus=${(e: Event) => this._onFieldFocus(e)}
681
+ class="compose-input text-[0.9rem]"
682
+ placeholder=${this.labels.urlPlaceholder}
683
+ />
684
+ </div>
685
+ <input
686
+ type="text"
687
+ .value=${this._title}
688
+ @input=${(e: Event) => this._onInput("_title", e)}
689
+ @focus=${(e: Event) => this._onFieldFocus(e)}
690
+ class="compose-input compose-link-title"
691
+ placeholder=${this.labels.linkTitlePlaceholder}
692
+ />
693
+ <div class="compose-divider"></div>
694
+ <div class="compose-tiptap-body compose-tiptap-thoughts"></div>
695
+ </div>
696
+ `;
697
+ }
698
+
699
+ private _renderQuoteFields() {
700
+ return html`
701
+ <div class="compose-field-enter">
702
+ <div class="compose-quote-wrap">
703
+ <span class="compose-quote-mark">"</span>
704
+ <textarea
705
+ .value=${this._quoteText}
706
+ @input=${(e: Event) => this._onInput("_quoteText", e)}
707
+ @focus=${(e: Event) => this._onFieldFocus(e)}
708
+ class="compose-input compose-quote-text"
709
+ placeholder=${this.labels.quotePlaceholder}
710
+ rows="3"
711
+ ></textarea>
712
+ </div>
713
+ <div class="compose-quote-author-row">
714
+ <span class="compose-quote-dash">—</span>
715
+ <input
716
+ type="text"
717
+ .value=${this._quoteAuthor}
718
+ @input=${(e: Event) => this._onInput("_quoteAuthor", e)}
719
+ @focus=${(e: Event) => this._onFieldFocus(e)}
720
+ class="compose-input compose-quote-author"
721
+ placeholder=${this.labels.authorPlaceholder}
722
+ />
723
+ </div>
724
+ <div class="compose-quote-source">
725
+ <input
726
+ type="url"
727
+ .value=${this._url}
728
+ @input=${(e: Event) => this._onInput("_url", e)}
729
+ @focus=${(e: Event) => this._onFieldFocus(e)}
730
+ class="compose-input text-[0.78rem]"
731
+ placeholder=${this.labels.sourcePlaceholder}
732
+ />
733
+ </div>
734
+ <div class="compose-divider"></div>
735
+ <div class="compose-tiptap-body compose-tiptap-thoughts"></div>
736
+ </div>
737
+ `;
738
+ }
739
+
740
+ private _renderStarRating() {
741
+ if (!this._showRating) return nothing;
742
+ const stars = [1, 2, 3, 4, 5];
743
+ return html`
744
+ <div class="compose-star-rating">
745
+ ${stars.map(
746
+ (n) => html`
747
+ <button
748
+ type="button"
749
+ class=${classMap({
750
+ "compose-star": true,
751
+ "compose-star-filled": this._rating >= n,
752
+ })}
753
+ @click=${() => this._setRating(n)}
754
+ >
755
+
756
+ </button>
757
+ `,
758
+ )}
759
+ ${this._rating > 0
760
+ ? html`<span class="compose-star-label">${this._rating}/5</span>`
761
+ : nothing}
762
+ </div>
763
+ `;
764
+ }
765
+
766
+ private _renderAttachedBadge() {
767
+ if (this._attachedText.trim().length === 0 || this._showAttachedText)
768
+ return nothing;
769
+ return html`
770
+ <div
771
+ class="compose-attached-badge"
772
+ @click=${() => this._openAttachedText()}
773
+ >
774
+ <svg
775
+ width="14"
776
+ height="14"
777
+ viewBox="0 0 18 18"
778
+ fill="none"
779
+ stroke="currentColor"
780
+ stroke-width="1.3"
781
+ stroke-linecap="round"
782
+ class="text-muted-foreground icon-fine"
783
+ >
784
+ <rect x="3" y="2" width="12" height="14" rx="2" />
785
+ <line x1="6" y1="6" x2="12" y2="6" />
786
+ <line x1="6" y1="9" x2="12" y2="9" />
787
+ <line x1="6" y1="12" x2="9.5" y2="12" />
788
+ </svg>
789
+ <span class="text-xs font-medium">${this.labels.attachedText}</span>
790
+ <span class="text-xs text-muted-foreground"
791
+ >· ${this._attachedText.length.toLocaleString()} chars</span
792
+ >
793
+ <div class="flex-1"></div>
794
+ <button
795
+ type="button"
796
+ class="compose-attached-badge-dismiss"
797
+ @click=${(e: Event) => {
798
+ e.stopPropagation();
799
+ this._attachedText = "";
800
+ }}
801
+ >
802
+
803
+ </button>
804
+ </div>
805
+ `;
806
+ }
807
+
808
+ private _renderAttachmentPreview(a: ComposeAttachment) {
809
+ const category = this._getCategory(a);
810
+
811
+ if (category === "video") {
812
+ return html`
813
+ <div class="compose-attachment-thumb">
814
+ <video
815
+ src=${a.previewUrl}
816
+ class="compose-attachment-img"
817
+ preload="metadata"
818
+ muted
819
+ ></video>
820
+ <div class="compose-attachment-play-icon">
821
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
822
+ <path d="M8 5v14l11-7z" />
823
+ </svg>
824
+ </div>
825
+ </div>
826
+ `;
827
+ }
828
+
829
+ if (category === "audio") {
830
+ return html`
831
+ <div class="compose-attachment-file-card">
832
+ <div class="compose-attachment-file-icon">
833
+ <svg
834
+ width="20"
835
+ height="20"
836
+ viewBox="0 0 24 24"
837
+ fill="none"
838
+ stroke="currentColor"
839
+ stroke-width="1.5"
840
+ stroke-linecap="round"
841
+ stroke-linejoin="round"
842
+ >
843
+ <path d="M9 18V5l12-2v13" />
844
+ <circle cx="6" cy="18" r="3" />
845
+ <circle cx="18" cy="16" r="3" />
846
+ </svg>
847
+ </div>
848
+ <span class="compose-attachment-file-name">${a.file.name}</span>
849
+ </div>
850
+ `;
851
+ }
852
+
853
+ if (category === "document") {
854
+ return html`
855
+ <div class="compose-attachment-file-card">
856
+ <div class="compose-attachment-file-icon">
857
+ <svg
858
+ width="20"
859
+ height="20"
860
+ viewBox="0 0 24 24"
861
+ fill="none"
862
+ stroke="currentColor"
863
+ stroke-width="1.5"
864
+ stroke-linecap="round"
865
+ stroke-linejoin="round"
866
+ >
867
+ <path
868
+ d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
869
+ />
870
+ <polyline points="14 2 14 8 20 8" />
871
+ <line x1="16" y1="13" x2="8" y2="13" />
872
+ <line x1="16" y1="17" x2="8" y2="17" />
873
+ </svg>
874
+ </div>
875
+ <span class="compose-attachment-file-name">${a.file.name}</span>
876
+ <span class="compose-attachment-file-size"
877
+ >${this._formatSize(a.file.size)}</span
878
+ >
879
+ </div>
880
+ `;
881
+ }
882
+
883
+ // Default: image
884
+ return html`
885
+ <div class="compose-attachment-thumb">
886
+ <img src=${a.previewUrl} alt="" class="compose-attachment-img" />
887
+ </div>
888
+ `;
889
+ }
890
+
891
+ private _renderAttachmentOverlay(a: ComposeAttachment, index: number) {
892
+ return html`
893
+ ${a.status === "pending" || a.status === "uploading"
894
+ ? html`
895
+ <div class="compose-attachment-overlay">
896
+ <svg
897
+ class="animate-spin size-4"
898
+ viewBox="0 0 24 24"
899
+ fill="none"
900
+ stroke="currentColor"
901
+ style="stroke-width: 2.5"
902
+ stroke-linecap="round"
903
+ >
904
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
905
+ </svg>
906
+ </div>
907
+ `
908
+ : nothing}
909
+ ${a.status === "error"
910
+ ? html`
911
+ <button
912
+ type="button"
913
+ class="compose-attachment-overlay compose-attachment-retry"
914
+ title="${a.error ?? "Upload failed"}. ${this.labels.retryAll}"
915
+ @click=${(e: Event) => {
916
+ e.stopPropagation();
917
+ this._retryAllFailed();
918
+ }}
919
+ >
920
+ <svg
921
+ width="20"
922
+ height="20"
923
+ viewBox="0 0 24 24"
924
+ fill="none"
925
+ stroke="currentColor"
926
+ stroke-width="2"
927
+ stroke-linecap="round"
928
+ stroke-linejoin="round"
929
+ >
930
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
931
+ <path d="M3 3v5h5" />
932
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
933
+ <path d="M16 16h5v5" />
934
+ </svg>
935
+ </button>
936
+ `
937
+ : nothing}
938
+ <button
939
+ type="button"
940
+ class="compose-attachment-remove"
941
+ @click=${() => this._removeAttachment(index)}
942
+ >
943
+
944
+ </button>
945
+ `;
946
+ }
947
+
948
+ private _renderAttachments() {
949
+ if (this._attachments.length === 0) return nothing;
950
+
951
+ return html`
952
+ <div class="compose-attachments">
953
+ ${this._attachments.map((a, i) => {
954
+ const category = this._getCategory(a);
955
+ const isFileCard = category === "audio" || category === "document";
956
+
957
+ return html`
958
+ <div class="compose-attachment">
959
+ ${isFileCard
960
+ ? html`
961
+ <div class="compose-attachment-thumb">
962
+ ${this._renderAttachmentPreview(a)}
963
+ ${this._renderAttachmentOverlay(a, i)}
964
+ </div>
965
+ `
966
+ : html`
967
+ <div class="compose-attachment-thumb">
968
+ ${category === "video"
969
+ ? html`
970
+ <video
971
+ src=${a.previewUrl}
972
+ class="compose-attachment-img"
973
+ preload="metadata"
974
+ muted
975
+ ></video>
976
+ <div class="compose-attachment-play-icon">
977
+ <svg
978
+ width="24"
979
+ height="24"
980
+ viewBox="0 0 24 24"
981
+ fill="white"
982
+ >
983
+ <path d="M8 5v14l11-7z" />
984
+ </svg>
985
+ </div>
986
+ `
987
+ : html`
988
+ <img
989
+ src=${a.previewUrl}
990
+ alt=""
991
+ class="compose-attachment-img"
992
+ />
993
+ `}
994
+ ${this._renderAttachmentOverlay(a, i)}
995
+ </div>
996
+ `}
997
+ <button
998
+ type="button"
999
+ class=${classMap({
1000
+ "compose-attachment-alt": true,
1001
+ "compose-attachment-alt-set": a.alt.length > 0,
1002
+ })}
1003
+ @click=${() => this._openAltPanel(i)}
1004
+ >
1005
+ ${a.alt.length > 0 ? "ALT" : "+ ALT"}
1006
+ </button>
1007
+ </div>
1008
+ `;
1009
+ })}
1010
+ </div>
1011
+ `;
1012
+ }
1013
+
1014
+ private _renderToolsRow() {
1015
+ const hasAttached = this._attachedText.trim().length > 0;
1016
+ return html`
1017
+ <div class="compose-tools-row">
1018
+ <!-- Media / Add -->
1019
+ <button
1020
+ type="button"
1021
+ class=${classMap({
1022
+ "compose-tool-btn": true,
1023
+ "compose-tool-btn-add": this._attachments.length > 0,
1024
+ })}
1025
+ title=${this._attachments.length > 0 ? "" : this.labels.media}
1026
+ @click=${() => this._openFilePicker()}
1027
+ >
1028
+ <svg
1029
+ class="icon-fine"
1030
+ width="18"
1031
+ height="18"
1032
+ viewBox="0 0 18 18"
1033
+ fill="none"
1034
+ stroke="currentColor"
1035
+ stroke-width="1.4"
1036
+ stroke-linecap="round"
1037
+ stroke-linejoin="round"
1038
+ >
1039
+ <rect x="2" y="3" width="14" height="12" rx="2.5" />
1040
+ <circle cx="6.5" cy="7.5" r="1.5" />
1041
+ <path d="M2 13l4-4c.6-.6 1.4-.6 2 0l4 4" />
1042
+ <path d="M11 11l1.5-1.5c.6-.6 1.4-.6 2 0L16 11" />
1043
+ </svg>
1044
+ ${this._attachments.length > 0
1045
+ ? html`<span class="compose-tool-label"
1046
+ >${this.labels.addMore}</span
1047
+ >`
1048
+ : nothing}
1049
+ </button>
1050
+
1051
+ <!-- Attached Text -->
1052
+ <button
1053
+ type="button"
1054
+ class=${classMap({
1055
+ "compose-tool-btn": true,
1056
+ "compose-tool-btn-active": hasAttached,
1057
+ })}
1058
+ title=${this.labels.attachedText}
1059
+ @click=${() => this._openAttachedText()}
1060
+ >
1061
+ <svg
1062
+ class="icon-fine"
1063
+ width="18"
1064
+ height="18"
1065
+ viewBox="0 0 18 18"
1066
+ fill="none"
1067
+ stroke="currentColor"
1068
+ stroke-width="1.3"
1069
+ stroke-linecap="round"
1070
+ >
1071
+ <rect x="3" y="2" width="12" height="14" rx="2" />
1072
+ <line x1="6" y1="6" x2="12" y2="6" />
1073
+ <line x1="6" y1="9" x2="12" y2="9" />
1074
+ <line x1="6" y1="12" x2="9.5" y2="12" />
1075
+ </svg>
1076
+ </button>
1077
+
1078
+ <!-- Rate -->
1079
+ <button
1080
+ type="button"
1081
+ class=${classMap({
1082
+ "compose-tool-btn": true,
1083
+ "compose-tool-btn-active": this._showRating,
1084
+ })}
1085
+ title=${this.labels.rate}
1086
+ @click=${() => {
1087
+ this._showRating = !this._showRating;
1088
+ }}
1089
+ >
1090
+ <svg
1091
+ class="icon-fine"
1092
+ width="18"
1093
+ height="18"
1094
+ viewBox="0 0 24 24"
1095
+ fill="none"
1096
+ >
1097
+ <defs>
1098
+ <clipPath id="half-left">
1099
+ <rect x="0" y="0" width="12" height="24" />
1100
+ </clipPath>
1101
+ </defs>
1102
+ <polygon
1103
+ 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"
1104
+ fill="currentColor"
1105
+ opacity="0.45"
1106
+ clip-path="url(#half-left)"
1107
+ />
1108
+ <polygon
1109
+ 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"
1110
+ fill="none"
1111
+ stroke="currentColor"
1112
+ stroke-width="2.4"
1113
+ stroke-linejoin="round"
1114
+ />
1115
+ </svg>
1116
+ </button>
1117
+
1118
+ <!-- Emoji -->
1119
+ <button
1120
+ type="button"
1121
+ class=${classMap({
1122
+ "compose-tool-btn": true,
1123
+ "compose-emoji-btn": true,
1124
+ "compose-tool-btn-active": this._showEmojiPicker,
1125
+ })}
1126
+ title=${this.labels.emoji}
1127
+ @click=${() => this._toggleEmojiPicker()}
1128
+ >
1129
+ <svg
1130
+ class="icon-fine"
1131
+ width="18"
1132
+ height="18"
1133
+ viewBox="0 0 18 18"
1134
+ fill="none"
1135
+ stroke="currentColor"
1136
+ stroke-width="1.4"
1137
+ stroke-linecap="round"
1138
+ stroke-linejoin="round"
1139
+ >
1140
+ <circle cx="9" cy="9" r="7" />
1141
+ <path d="M6 10.5c.5 1.2 1.5 2 3 2s2.5-.8 3-2" />
1142
+ <circle cx="6.5" cy="7" r="0.5" fill="currentColor" stroke="none" />
1143
+ <circle
1144
+ cx="11.5"
1145
+ cy="7"
1146
+ r="0.5"
1147
+ fill="currentColor"
1148
+ stroke="none"
1149
+ />
1150
+ </svg>
1151
+ </button>
1152
+
1153
+ <!-- Title toggle (Note only) -->
1154
+ ${this.format === "note"
1155
+ ? html`
1156
+ <div class="flex items-center gap-0.5">
1157
+ <div class="compose-tool-sep"></div>
1158
+ <button
1159
+ type="button"
1160
+ class=${classMap({
1161
+ "compose-tool-btn": true,
1162
+ "compose-tool-btn-active": this._showTitle,
1163
+ })}
1164
+ title=${this.labels.title}
1165
+ @click=${() => {
1166
+ const willShow = !this._showTitle;
1167
+ this._showTitle = willShow;
1168
+ if (willShow) {
1169
+ this.updateComplete.then(() => {
1170
+ this.querySelector<HTMLInputElement>(
1171
+ ".compose-note-title",
1172
+ )?.focus();
1173
+ });
1174
+ }
1175
+ }}
1176
+ >
1177
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
1178
+ <text
1179
+ x="3.5"
1180
+ y="14"
1181
+ font-family="serif"
1182
+ font-size="14"
1183
+ font-weight="400"
1184
+ fill="currentColor"
1185
+ >
1186
+ T
1187
+ </text>
1188
+ </svg>
1189
+ </button>
1190
+ </div>
1191
+ `
1192
+ : nothing}
1193
+
1194
+ <div class="flex-1"></div>
1195
+
1196
+ <!-- Expand to fullscreen -->
1197
+ <button
1198
+ type="button"
1199
+ class="compose-tool-btn"
1200
+ @click=${() => this._openFullscreen()}
1201
+ >
1202
+ <svg
1203
+ class="icon-fine"
1204
+ width="18"
1205
+ height="18"
1206
+ viewBox="0 0 18 18"
1207
+ fill="none"
1208
+ stroke="currentColor"
1209
+ stroke-width="1.4"
1210
+ stroke-linecap="round"
1211
+ stroke-linejoin="round"
1212
+ >
1213
+ <polyline points="6 2 2 2 2 6" />
1214
+ <polyline points="12 16 16 16 16 12" />
1215
+ <line x1="2" y1="2" x2="7" y2="7" />
1216
+ <line x1="16" y1="16" x2="11" y2="11" />
1217
+ </svg>
1218
+ </button>
1219
+ </div>
1220
+ `;
1221
+ }
1222
+
1223
+ private _openFullscreen() {
1224
+ const state = this.getEditorState();
1225
+ this.dispatchEvent(
1226
+ new CustomEvent("jant:fullscreen-open", {
1227
+ bubbles: true,
1228
+ detail: { ...state, labels: this.labels },
1229
+ }),
1230
+ );
1231
+ }
1232
+
1233
+ render() {
1234
+ return html`
1235
+ <section class="compose-body">
1236
+ ${this.format === "note"
1237
+ ? this._renderNoteFields()
1238
+ : this.format === "link"
1239
+ ? this._renderLinkFields()
1240
+ : this._renderQuoteFields()}
1241
+ ${this._renderStarRating()} ${this._renderAttachedBadge()}
1242
+ ${this._renderAttachments()}
1243
+ </section>
1244
+ ${this._renderToolsRow()}
1245
+ `;
1246
+ }
1247
+ }
1248
+
1249
+ customElements.define("jant-compose-editor", JantComposeEditor);