@jant/core 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3327 -3031
- 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 +245 -6
- package/src/routes/feed/rss.ts +70 -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,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
|
+
}
|