@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,371 @@
1
+ /**
2
+ * Link Toolbar Extension
3
+ *
4
+ * Floating toolbar for link editing with two modes:
5
+ * - Input mode: light popup with URL field + confirm button (speech-bubble arrow)
6
+ * - Preview mode: dark tooltip showing truncated URL + edit/delete buttons
7
+ *
8
+ * Replaces the browser prompt() dialog for link creation.
9
+ */
10
+
11
+ import { Extension } from "@tiptap/core";
12
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
13
+ import type { EditorState } from "@tiptap/pm/state";
14
+ import type { EditorView } from "@tiptap/pm/view";
15
+
16
+ const linkToolbarKey = new PluginKey("linkToolbar");
17
+
18
+ type Mode = "hidden" | "input" | "preview";
19
+ let currentMode: Mode = "hidden";
20
+
21
+ /** Returns true when the link input popup is visible. Used by bubble menu to hide itself. */
22
+ export function isLinkToolbarInputActive(): boolean {
23
+ return currentMode === "input";
24
+ }
25
+
26
+ // SVG icons (14×14 for preview buttons, 16×16 for confirm)
27
+ const ICON_ENTER = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>`;
28
+ const ICON_EDIT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>`;
29
+ const ICON_TRASH = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`;
30
+
31
+ interface LinkRange {
32
+ from: number;
33
+ to: number;
34
+ href: string;
35
+ }
36
+
37
+ /** Find the extent of a link mark around the cursor position. */
38
+ function getLinkRange(state: EditorState): LinkRange | null {
39
+ const { selection } = state;
40
+ if (!selection.empty) return null;
41
+
42
+ const $pos = selection.$from;
43
+ const linkType = state.schema.marks.link;
44
+ if (!linkType) return null;
45
+
46
+ const marks = $pos.marks();
47
+ const linkMark = marks.find((m) => m.type === linkType);
48
+ if (!linkMark) return null;
49
+
50
+ // Walk the parent node's children to find the text range covered by this link
51
+ const parent = $pos.parent;
52
+ const parentOffset = $pos.start();
53
+ let from = 0;
54
+ let to = 0;
55
+ let found = false;
56
+ let offset = 0;
57
+
58
+ for (let i = 0; i < parent.childCount; i++) {
59
+ const child = parent.child(i);
60
+ const childFrom = parentOffset + offset;
61
+ const childTo = childFrom + child.nodeSize;
62
+
63
+ if (
64
+ child.marks.some(
65
+ (m) => m.type === linkType && m.attrs.href === linkMark.attrs.href,
66
+ )
67
+ ) {
68
+ if (!found) {
69
+ from = childFrom;
70
+ found = true;
71
+ }
72
+ to = childTo;
73
+ } else if (found) {
74
+ break;
75
+ }
76
+
77
+ offset += child.nodeSize;
78
+ }
79
+
80
+ if (!found) return null;
81
+ return { from, to, href: linkMark.attrs.href as string };
82
+ }
83
+
84
+ export const LinkToolbar = Extension.create({
85
+ name: "linkToolbar",
86
+
87
+ addProseMirrorPlugins() {
88
+ const editor = this.editor;
89
+
90
+ // DOM elements
91
+ let inputEl: HTMLElement | null = null;
92
+ let previewEl: HTMLElement | null = null;
93
+ let inputField: HTMLInputElement | null = null;
94
+
95
+ // State
96
+ let savedFrom = 0;
97
+ let savedTo = 0;
98
+ let suppressNextUpdate = false;
99
+ let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
100
+
101
+ function createElements() {
102
+ // --- Input popup ---
103
+ inputEl = document.createElement("div");
104
+ inputEl.className = "tiptap-link-input";
105
+ inputEl.style.display = "none";
106
+
107
+ inputField = document.createElement("input");
108
+ inputField.type = "url";
109
+ inputField.className = "tiptap-link-input-field";
110
+ inputField.placeholder = "https://";
111
+
112
+ const confirmBtn = document.createElement("button");
113
+ confirmBtn.type = "button";
114
+ confirmBtn.className = "tiptap-link-input-confirm";
115
+ confirmBtn.innerHTML = ICON_ENTER;
116
+ confirmBtn.title = "Apply link";
117
+
118
+ inputEl.appendChild(inputField);
119
+ inputEl.appendChild(confirmBtn);
120
+
121
+ // Input key handling
122
+ inputField.addEventListener("keydown", (e) => {
123
+ if (e.key === "Enter") {
124
+ e.preventDefault();
125
+ confirmLink();
126
+ } else if (e.key === "Escape") {
127
+ e.preventDefault();
128
+ hideAll();
129
+ editor.commands.focus();
130
+ }
131
+ });
132
+
133
+ // Confirm button
134
+ confirmBtn.addEventListener("mousedown", (e) => {
135
+ e.preventDefault();
136
+ confirmLink();
137
+ });
138
+
139
+ // Prevent input popup clicks from bubbling
140
+ inputEl.addEventListener("mousedown", (e) => {
141
+ e.stopPropagation();
142
+ });
143
+
144
+ // --- Preview tooltip ---
145
+ previewEl = document.createElement("div");
146
+ previewEl.className = "tiptap-link-preview";
147
+ previewEl.style.display = "none";
148
+
149
+ const urlSpan = document.createElement("span");
150
+ urlSpan.className = "tiptap-link-preview-url";
151
+
152
+ const editBtn = document.createElement("button");
153
+ editBtn.type = "button";
154
+ editBtn.className = "tiptap-link-preview-btn";
155
+ editBtn.innerHTML = ICON_EDIT;
156
+ editBtn.title = "Edit link";
157
+
158
+ const deleteBtn = document.createElement("button");
159
+ deleteBtn.type = "button";
160
+ deleteBtn.className = "tiptap-link-preview-btn";
161
+ deleteBtn.innerHTML = ICON_TRASH;
162
+ deleteBtn.title = "Remove link";
163
+
164
+ previewEl.appendChild(urlSpan);
165
+ previewEl.appendChild(editBtn);
166
+ previewEl.appendChild(deleteBtn);
167
+
168
+ // Edit button — switch to input with current href
169
+ editBtn.addEventListener("mousedown", (e) => {
170
+ e.preventDefault();
171
+ const url = urlSpan.textContent ?? "";
172
+ const range = getLinkRange(editor.state);
173
+ if (range) {
174
+ showInput(editor.view, url, range.from, range.to);
175
+ }
176
+ });
177
+
178
+ // Delete button — remove link
179
+ deleteBtn.addEventListener("mousedown", (e) => {
180
+ e.preventDefault();
181
+ hideAll();
182
+ editor.chain().focus().unsetLink().run();
183
+ });
184
+
185
+ // Prevent preview clicks from bubbling
186
+ previewEl.addEventListener("mousedown", (e) => {
187
+ e.stopPropagation();
188
+ });
189
+ }
190
+
191
+ function positionAbove(
192
+ el: HTMLElement,
193
+ view: EditorView,
194
+ from: number,
195
+ to: number,
196
+ ) {
197
+ const start = view.coordsAtPos(from);
198
+ const end = view.coordsAtPos(to);
199
+ const cx = (start.left + end.right) / 2;
200
+ const top = start.top;
201
+
202
+ const dialog = view.dom.closest("dialog");
203
+ const offsetX = dialog?.getBoundingClientRect().left ?? 0;
204
+ const offsetY = dialog?.getBoundingClientRect().top ?? 0;
205
+
206
+ el.style.display = "flex";
207
+ const rect = el.getBoundingClientRect();
208
+ el.style.left = `${cx - rect.width / 2 - offsetX}px`;
209
+ el.style.top = `${top - rect.height - 8 - offsetY}px`;
210
+ }
211
+
212
+ function showInput(
213
+ view: EditorView,
214
+ href: string,
215
+ from?: number,
216
+ to?: number,
217
+ ) {
218
+ if (!inputEl || !inputField) return;
219
+
220
+ // Save selection range
221
+ if (from !== undefined && to !== undefined) {
222
+ savedFrom = from;
223
+ savedTo = to;
224
+ } else {
225
+ savedFrom = view.state.selection.from;
226
+ savedTo = view.state.selection.to;
227
+ }
228
+
229
+ // Hide preview if showing
230
+ if (previewEl) previewEl.style.display = "none";
231
+
232
+ currentMode = "input";
233
+ inputField.value = href;
234
+ positionAbove(inputEl, view, savedFrom, savedTo);
235
+
236
+ // Focus field after a tick so positioning is settled
237
+ const field = inputField;
238
+ requestAnimationFrame(() => {
239
+ field.focus();
240
+ field.select();
241
+ });
242
+
243
+ // Register outside-click handler
244
+ removeOutsideClickHandler();
245
+ outsideClickHandler = (e: MouseEvent) => {
246
+ if (inputEl && !inputEl.contains(e.target as Node)) {
247
+ hideAll();
248
+ // Don't refocus editor here — user clicked somewhere intentionally
249
+ }
250
+ };
251
+ // Use setTimeout so the current click doesn't immediately trigger it
252
+ setTimeout(() => {
253
+ if (outsideClickHandler) {
254
+ document.addEventListener("mousedown", outsideClickHandler, true);
255
+ }
256
+ }, 0);
257
+ }
258
+
259
+ function showPreview(view: EditorView, range: LinkRange) {
260
+ if (!previewEl) return;
261
+
262
+ currentMode = "preview";
263
+ const urlSpan = previewEl.querySelector(".tiptap-link-preview-url");
264
+ if (urlSpan) {
265
+ // Truncate display URL
266
+ const display =
267
+ range.href.length > 40 ? range.href.slice(0, 40) + "…" : range.href;
268
+ urlSpan.textContent = display;
269
+ urlSpan.setAttribute("title", range.href);
270
+ }
271
+
272
+ positionAbove(previewEl, view, range.from, range.to);
273
+ }
274
+
275
+ function hideAll() {
276
+ if (inputEl) inputEl.style.display = "none";
277
+ if (previewEl) previewEl.style.display = "none";
278
+ currentMode = "hidden";
279
+ removeOutsideClickHandler();
280
+ }
281
+
282
+ function removeOutsideClickHandler() {
283
+ if (outsideClickHandler) {
284
+ document.removeEventListener("mousedown", outsideClickHandler, true);
285
+ outsideClickHandler = null;
286
+ }
287
+ }
288
+
289
+ function confirmLink() {
290
+ if (!inputField) return;
291
+ const url = inputField.value.trim();
292
+ hideAll();
293
+
294
+ if (url) {
295
+ // Restore selection and apply link
296
+ editor
297
+ .chain()
298
+ .focus()
299
+ .setTextSelection({ from: savedFrom, to: savedTo })
300
+ .setLink({ href: url })
301
+ .run();
302
+ } else {
303
+ // Empty URL — remove link if one existed
304
+ editor
305
+ .chain()
306
+ .focus()
307
+ .setTextSelection({ from: savedFrom, to: savedTo })
308
+ .unsetLink()
309
+ .run();
310
+ }
311
+
312
+ // Suppress the next update so the newly-created link doesn't trigger preview
313
+ suppressNextUpdate = true;
314
+ }
315
+
316
+ return [
317
+ new Plugin({
318
+ key: linkToolbarKey,
319
+ view(editorView) {
320
+ createElements();
321
+ const dialog = editorView.dom.closest("dialog");
322
+ if (inputEl) (dialog ?? document.body).appendChild(inputEl);
323
+ if (previewEl) (dialog ?? document.body).appendChild(previewEl);
324
+
325
+ // Listen for bubble menu link button
326
+ const handler = () => {
327
+ showInput(editorView, "");
328
+ };
329
+ editorView.dom.addEventListener("tiptap:open-link-input", handler);
330
+
331
+ return {
332
+ update(view) {
333
+ if (suppressNextUpdate) {
334
+ suppressNextUpdate = false;
335
+ return;
336
+ }
337
+
338
+ // While input is open, just reposition
339
+ if (currentMode === "input") {
340
+ if (inputEl) {
341
+ positionAbove(inputEl, view, savedFrom, savedTo);
342
+ }
343
+ return;
344
+ }
345
+
346
+ // Detect link under cursor for preview mode
347
+ const range = getLinkRange(view.state);
348
+ if (range) {
349
+ showPreview(view, range);
350
+ } else if (currentMode === "preview") {
351
+ hideAll();
352
+ }
353
+ },
354
+ destroy() {
355
+ editorView.dom.removeEventListener(
356
+ "tiptap:open-link-input",
357
+ handler,
358
+ );
359
+ removeOutsideClickHandler();
360
+ inputEl?.remove();
361
+ previewEl?.remove();
362
+ inputEl = null;
363
+ previewEl = null;
364
+ currentMode = "hidden";
365
+ },
366
+ };
367
+ },
368
+ }),
369
+ ];
370
+ },
371
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MoreBreak Node Extension
3
+ *
4
+ * Custom Tiptap node that renders as a dashed "Read More" separator.
5
+ * Atom node — not editable, but selectable and deletable.
6
+ * Server-side renders to <!--more--> for excerpt splitting.
7
+ */
8
+
9
+ import { Node } from "@tiptap/core";
10
+
11
+ declare module "@tiptap/core" {
12
+ interface Commands<ReturnType> {
13
+ moreBreak: {
14
+ insertMoreBreak: () => ReturnType;
15
+ };
16
+ }
17
+ }
18
+
19
+ export const MoreBreak = Node.create({
20
+ name: "moreBreak",
21
+ group: "block",
22
+ atom: true,
23
+ selectable: true,
24
+ draggable: false,
25
+
26
+ parseHTML() {
27
+ return [{ tag: "div[data-more-break]" }];
28
+ },
29
+
30
+ renderHTML() {
31
+ return [
32
+ "div",
33
+ {
34
+ "data-more-break": "",
35
+ class: "tiptap-more-break",
36
+ },
37
+ "Read More ↓",
38
+ ];
39
+ },
40
+
41
+ addCommands() {
42
+ return {
43
+ insertMoreBreak:
44
+ () =>
45
+ ({ commands }) => {
46
+ return commands.insertContent({ type: this.name });
47
+ },
48
+ };
49
+ },
50
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Paste Image Extension
3
+ *
4
+ * Intercepts paste events containing images and either:
5
+ * - Uploads inline (if post has a title → image becomes part of body)
6
+ * - Dispatches as attachment (if no title → goes to attachment strip)
7
+ */
8
+
9
+ import { Extension } from "@tiptap/core";
10
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
11
+
12
+ const pasteImagePluginKey = new PluginKey("pasteImage");
13
+
14
+ export const PasteImage = Extension.create({
15
+ name: "pasteImage",
16
+
17
+ addStorage() {
18
+ return {
19
+ hasTitle: false,
20
+ };
21
+ },
22
+
23
+ addProseMirrorPlugins() {
24
+ const extension = this;
25
+
26
+ return [
27
+ new Plugin({
28
+ key: pasteImagePluginKey,
29
+ props: {
30
+ handlePaste(view, event) {
31
+ const items = event.clipboardData?.items;
32
+ if (!items) return false;
33
+
34
+ const imageFiles: File[] = [];
35
+ for (const item of items) {
36
+ if (item.type.startsWith("image/")) {
37
+ const file = item.getAsFile();
38
+ if (file) imageFiles.push(file);
39
+ }
40
+ }
41
+
42
+ if (imageFiles.length === 0) return false;
43
+
44
+ event.preventDefault();
45
+
46
+ const hasTitle = extension.storage.hasTitle;
47
+
48
+ if (hasTitle) {
49
+ // Upload and insert inline
50
+ for (const file of imageFiles) {
51
+ uploadAndInsertImage(file, extension.editor);
52
+ }
53
+ } else {
54
+ // Dispatch as attachment (existing flow)
55
+ const files = imageFiles.map((file) => ({
56
+ file,
57
+ clientId: crypto.randomUUID(),
58
+ }));
59
+ document.dispatchEvent(
60
+ new CustomEvent("jant:files-selected", {
61
+ bubbles: true,
62
+ detail: { files },
63
+ }),
64
+ );
65
+ }
66
+
67
+ return true;
68
+ },
69
+ },
70
+ }),
71
+ ];
72
+ },
73
+ });
74
+
75
+ async function uploadAndInsertImage(
76
+ file: File,
77
+ editor: import("@tiptap/core").Editor,
78
+ ) {
79
+ // Insert placeholder
80
+ const placeholderUrl = URL.createObjectURL(file);
81
+ editor.chain().focus().setImage({ src: placeholderUrl }).run();
82
+
83
+ try {
84
+ const formData = new FormData();
85
+ formData.append("file", file);
86
+
87
+ const response = await fetch("/api/upload", {
88
+ method: "POST",
89
+ body: formData,
90
+ });
91
+
92
+ if (!response.ok) {
93
+ throw new Error(`Upload failed: ${response.status}`);
94
+ }
95
+
96
+ const data = (await response.json()) as { url: string };
97
+
98
+ // Replace placeholder URL with actual URL in the document
99
+ const { doc } = editor.state;
100
+ let replaced = false;
101
+ doc.descendants((node, pos) => {
102
+ if (
103
+ replaced ||
104
+ node.type.name !== "image" ||
105
+ node.attrs.src !== placeholderUrl
106
+ ) {
107
+ return;
108
+ }
109
+
110
+ editor
111
+ .chain()
112
+ .focus()
113
+ .command(({ tr }) => {
114
+ tr.setNodeMarkup(pos, undefined, {
115
+ ...node.attrs,
116
+ src: data.url,
117
+ });
118
+ return true;
119
+ })
120
+ .run();
121
+ replaced = true;
122
+ });
123
+ } catch {
124
+ // Remove the placeholder image on failure
125
+ const { doc } = editor.state;
126
+ doc.descendants((node, pos) => {
127
+ if (node.type.name === "image" && node.attrs.src === placeholderUrl) {
128
+ editor
129
+ .chain()
130
+ .command(({ tr }) => {
131
+ tr.delete(pos, pos + node.nodeSize);
132
+ return true;
133
+ })
134
+ .run();
135
+ }
136
+ });
137
+ } finally {
138
+ URL.revokeObjectURL(placeholderUrl);
139
+ }
140
+ }