@jant/core 0.3.35 → 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.
- package/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-8Dj-5CLW.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +3109 -2294
- package/dist/index.js +3026 -2778
- package/package.json +13 -4
- package/src/__tests__/helpers/app.ts +1 -1
- package/src/__tests__/helpers/db.ts +6 -0
- package/src/app.tsx +1 -5
- package/src/{lib → client}/avatar-upload.ts +1 -1
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
- package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +45 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/{ui → client}/components/compose-types.ts +3 -1
- package/src/{ui → client}/components/jant-collection-form.ts +301 -182
- package/src/client/components/jant-collection-sidebar.ts +801 -0
- package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
- package/src/client/components/jant-compose-editor.ts +1249 -0
- package/src/client/components/jant-compose-fullscreen.ts +338 -0
- package/src/client/components/jant-media-lightbox.ts +257 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
- package/src/{ui → client}/components/jant-post-form.ts +57 -8
- package/src/{ui → client}/components/jant-settings-general.ts +2 -2
- package/src/{ui → client}/components/nav-manager-types.ts +3 -0
- package/src/{ui → client}/components/post-form-template.ts +35 -31
- package/src/{ui → client}/components/post-form-types.ts +7 -3
- package/src/{lib → client}/compose-bridge.ts +9 -7
- package/src/client/lazy-slugify.ts +51 -0
- package/src/{lib → client}/media-upload.ts +16 -3
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/client/page-slug-bridge.ts +42 -0
- package/src/{lib → client}/post-form-bridge.ts +2 -2
- package/src/{lib → client}/settings-bridge.ts +3 -3
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +40 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +60 -0
- package/src/client/tiptap/image-node.ts +488 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +140 -0
- package/src/client/tiptap/slash-commands.ts +328 -0
- package/src/{types → client/types}/sortablejs.d.ts +1 -1
- package/src/client.ts +24 -17
- package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
- package/src/db/schema.ts +6 -1
- package/src/i18n/locales/en.po +641 -215
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +642 -204
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +642 -204
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +9 -6
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +9 -9
- package/src/lib/emoji-catalog.ts +146 -0
- package/src/lib/feed.ts +1 -1
- package/src/lib/media-helpers.ts +10 -9
- package/src/lib/render.tsx +4 -3
- package/src/lib/resolve-config.ts +8 -1
- package/src/lib/schemas.ts +2 -3
- package/src/lib/summary.ts +92 -0
- package/src/lib/timeline.ts +2 -0
- package/src/lib/tiptap-render.ts +196 -0
- package/src/lib/upload.ts +97 -9
- package/src/lib/url.ts +7 -23
- package/src/lib/view.ts +33 -19
- package/src/middleware/error-handler.ts +3 -3
- package/src/preset.css +38 -0
- package/src/routes/api/collections.ts +20 -3
- package/src/routes/api/posts.ts +48 -33
- package/src/routes/api/upload.ts +7 -5
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +26 -11
- package/src/routes/auth/signin.tsx +10 -7
- package/src/routes/compose.tsx +20 -11
- package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
- package/src/routes/dash/index.tsx +7 -1
- package/src/routes/dash/media.tsx +3 -0
- package/src/routes/dash/pages.tsx +8 -2
- package/src/routes/dash/posts.tsx +6 -2
- package/src/routes/dash/redirects.tsx +15 -9
- package/src/routes/dash/settings.tsx +336 -32
- package/src/routes/feed/__tests__/rss.test.ts +7 -7
- package/src/routes/feed/rss.ts +8 -6
- package/src/routes/pages/__tests__/featured.test.ts +6 -7
- package/src/routes/pages/archive.tsx +11 -7
- package/src/routes/pages/collection.tsx +32 -15
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +1 -1
- package/src/routes/pages/home.tsx +1 -1
- package/src/services/__tests__/post.test.ts +124 -33
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/page.ts +16 -3
- package/src/services/post.ts +96 -37
- package/src/services/search.ts +4 -2
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +240 -60
- package/src/styles/tokens.css +10 -0
- package/src/styles/ui.css +1157 -81
- package/src/types/bindings.ts +5 -0
- package/src/types/config.ts +23 -1
- package/src/types/constants.ts +3 -0
- package/src/types/entities.ts +9 -2
- package/src/types/operations.ts +9 -3
- package/src/types/props.ts +3 -3
- package/src/types/views.ts +3 -2
- package/src/ui/compose/ComposeDialog.tsx +24 -7
- package/src/ui/dash/PageForm.tsx +2 -0
- package/src/ui/dash/PostList.tsx +5 -5
- package/src/ui/dash/StatusBadge.tsx +13 -5
- package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
- package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
- package/src/ui/dash/media/MediaListContent.tsx +9 -4
- package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
- package/src/ui/dash/pages/PagesContent.tsx +2 -1
- package/src/ui/dash/posts/PostForm.tsx +19 -7
- package/src/ui/dash/settings/AccountContent.tsx +133 -138
- package/src/ui/dash/settings/AvatarContent.tsx +70 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
- package/src/ui/layouts/DashLayout.tsx +157 -75
- package/src/ui/layouts/SiteLayout.tsx +13 -13
- package/src/ui/pages/ArchivePage.tsx +10 -7
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/SearchPage.tsx +1 -1
- package/src/ui/shared/CollectionsSidebar.tsx +228 -3
- package/src/ui/shared/MediaGallery.tsx +179 -41
- package/src/lib/collections-reorder.ts +0 -28
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
- /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
- /package/src/{ui → client}/components/settings-types.ts +0 -0
- /package/src/{lib → client}/image-processor.ts +0 -0
- /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);
|