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