@jant/core 0.6.9 → 0.6.11
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/{app-C-jxWmAV.js → app-CpmficmQ.js} +912 -423
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DWy1LEEk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-DkpSdIDz.js} +101 -99
- package/dist/{export-C2DIB7mm.js → export-Ba7NJImL.js} +93 -93
- package/dist/{github-sync-7XQ5ZM6z.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-BEFCfLKK.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-settings-general.ts +18 -3
- package/src/client/components/settings-types.ts +2 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/link-toolbar.ts +63 -1
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +258 -18
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +258 -18
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +258 -18
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +5 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +6 -3
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +7 -2
- package/src/routes/feed/__tests__/feed.test.ts +58 -19
- package/src/routes/feed/feed.ts +37 -28
- package/src/routes/pages/featured.tsx +17 -0
- package/src/routes/pages/latest.tsx +25 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/services/post.ts +1 -1
- package/src/styles/tokens.css +15 -0
- package/src/styles/ui.css +45 -2
- package/src/ui/__tests__/color-themes.test.ts +2 -2
- package/src/ui/color-themes.ts +32 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
- package/src/ui/dash/settings/GeneralContent.tsx +16 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
- package/src/ui/layouts/BaseLayout.tsx +14 -2
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
- package/dist/app-DqHzOwL5.js +0 -6
- package/dist/client/_assets/client-CGf2m3qp.css +0 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Editor, JSONContent } from "@tiptap/core";
|
|
9
9
|
import { uploadWithMetadata } from "../upload-with-metadata.js";
|
|
10
|
+
import { publicPath } from "../runtime-paths.js";
|
|
10
11
|
|
|
11
12
|
type InlineImageUpload = (file: File) => Promise<{ url: string }>;
|
|
12
13
|
|
|
@@ -26,12 +27,15 @@ const inflightUploads = new Map<string, Promise<string>>();
|
|
|
26
27
|
*/
|
|
27
28
|
const adoptedUploads = new Map<string, Promise<string>>();
|
|
28
29
|
|
|
29
|
-
function replaceInlineImage(editor: Editor,
|
|
30
|
+
function replaceInlineImage(editor: Editor, fromUrl: string, realUrl: string) {
|
|
30
31
|
if (editor.isDestroyed) return;
|
|
31
32
|
const { doc } = editor.state;
|
|
32
|
-
|
|
33
|
+
// Replace every node sharing this src. Blob placeholders are unique, but a
|
|
34
|
+
// deduped remote URL can appear in several nodes after a paste, and they all
|
|
35
|
+
// need to point at the one rehosted copy. setNodeMarkup preserves node size,
|
|
36
|
+
// so positions stay valid across the walk.
|
|
33
37
|
doc.descendants((node, pos) => {
|
|
34
|
-
if (
|
|
38
|
+
if (node.type.name !== "image" || node.attrs.src !== fromUrl) {
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
37
41
|
|
|
@@ -45,7 +49,6 @@ function replaceInlineImage(editor: Editor, blobUrl: string, realUrl: string) {
|
|
|
45
49
|
return true;
|
|
46
50
|
})
|
|
47
51
|
.run();
|
|
48
|
-
replaced = true;
|
|
49
52
|
});
|
|
50
53
|
}
|
|
51
54
|
|
|
@@ -65,6 +68,26 @@ function removeInlineImage(editor: Editor, blobUrl: string) {
|
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Apply the outcome of an inline upload/rehost to the placeholder node(s).
|
|
73
|
+
*
|
|
74
|
+
* On success the src is swapped to the final URL. On failure a `blob:`
|
|
75
|
+
* placeholder (from the insert flow) is removed, while a remote/`data:`
|
|
76
|
+
* placeholder (from the rehost flow) is left untouched so the original image
|
|
77
|
+
* still shows — rehosting is best-effort.
|
|
78
|
+
*/
|
|
79
|
+
function settlePlaceholder(
|
|
80
|
+
editor: Editor,
|
|
81
|
+
placeholderSrc: string,
|
|
82
|
+
realUrl: string | null,
|
|
83
|
+
) {
|
|
84
|
+
if (realUrl) {
|
|
85
|
+
replaceInlineImage(editor, placeholderSrc, realUrl);
|
|
86
|
+
} else if (placeholderSrc.startsWith("blob:")) {
|
|
87
|
+
removeInlineImage(editor, placeholderSrc);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
/**
|
|
69
92
|
* Uploads an image file and inserts it into the editor as an inline image.
|
|
70
93
|
*
|
|
@@ -93,9 +116,9 @@ export async function uploadAndInsertInlineImage(
|
|
|
93
116
|
|
|
94
117
|
try {
|
|
95
118
|
const realUrl = await uploaded;
|
|
96
|
-
|
|
119
|
+
settlePlaceholder(editor, placeholderUrl, realUrl);
|
|
97
120
|
} catch {
|
|
98
|
-
|
|
121
|
+
settlePlaceholder(editor, placeholderUrl, null);
|
|
99
122
|
} finally {
|
|
100
123
|
// Only cleanup if not adopted by another editor
|
|
101
124
|
if (inflightUploads.delete(placeholderUrl)) {
|
|
@@ -105,16 +128,88 @@ export async function uploadAndInsertInlineImage(
|
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
/**
|
|
108
|
-
*
|
|
131
|
+
* Result of the remote-image sideload endpoint.
|
|
132
|
+
*/
|
|
133
|
+
export interface SideloadResult {
|
|
134
|
+
id: string;
|
|
135
|
+
url: string;
|
|
136
|
+
width?: number;
|
|
137
|
+
height?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ask the server to rehost a remote image URL into the site's own storage.
|
|
142
|
+
*
|
|
143
|
+
* The server fetches the bytes (browser fetch of a third-party image is blocked
|
|
144
|
+
* by CORS), stores them, and returns the new public URL.
|
|
145
|
+
*
|
|
146
|
+
* @param url - The remote http(s) image URL
|
|
147
|
+
* @param alt - Optional alt text to persist on the media row
|
|
148
|
+
* @returns The created media's id, public URL, and dimensions
|
|
149
|
+
* @throws {Error} When the endpoint responds with a non-OK status
|
|
150
|
+
*/
|
|
151
|
+
export async function sideloadImage(
|
|
152
|
+
url: string,
|
|
153
|
+
alt?: string,
|
|
154
|
+
): Promise<SideloadResult> {
|
|
155
|
+
const res = await fetch(publicPath("/api/uploads/sideload"), {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ url, alt }),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
throw new Error(`Sideload failed: HTTP ${res.status}`);
|
|
162
|
+
}
|
|
163
|
+
return (await res.json()) as SideloadResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Rehost an image node that already exists in the document (inserted by a paste)
|
|
168
|
+
* whose `src` is a remote or `data:` URL. Tracks the work in the shared registry
|
|
169
|
+
* so submit waits for it, swaps the src to the stored URL on success, and leaves
|
|
170
|
+
* the node untouched on failure (the original image keeps showing).
|
|
171
|
+
*
|
|
172
|
+
* Does NOT insert a node — the paste already created it.
|
|
173
|
+
*
|
|
174
|
+
* @param editor - TipTap editor containing the placeholder node
|
|
175
|
+
* @param placeholderSrc - The current (remote/data) src of the node to rehost
|
|
176
|
+
* @param resolveUrl - Produces the final stored URL (server sideload or client upload)
|
|
177
|
+
* @returns Resolves after the node is updated or left in place
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* await rehostInlineImage(editor, src, () => sideloadImage(src).then((r) => r.url));
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export async function rehostInlineImage(
|
|
184
|
+
editor: Editor,
|
|
185
|
+
placeholderSrc: string,
|
|
186
|
+
resolveUrl: () => Promise<string>,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const uploaded = resolveUrl();
|
|
189
|
+
inflightUploads.set(placeholderSrc, uploaded);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const realUrl = await uploaded;
|
|
193
|
+
settlePlaceholder(editor, placeholderSrc, realUrl);
|
|
194
|
+
} catch {
|
|
195
|
+
settlePlaceholder(editor, placeholderSrc, null);
|
|
196
|
+
} finally {
|
|
197
|
+
inflightUploads.delete(placeholderSrc);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Adopt in-flight inline image uploads/rehosts into a new editor instance.
|
|
109
203
|
*
|
|
110
|
-
* Scans the editor's document for
|
|
111
|
-
*
|
|
112
|
-
*
|
|
204
|
+
* Scans the editor's document for image srcs that match pending registry entries
|
|
205
|
+
* (`blob:` placeholders from the insert flow, or remote/`data:` placeholders
|
|
206
|
+
* from the paste-rehost flow). For each match, takes ownership from the original
|
|
207
|
+
* editor and sets up replacement/removal watchers on the new editor.
|
|
113
208
|
*
|
|
114
|
-
* Call this immediately after `setContent` with JSON that may contain
|
|
115
|
-
* (e.g. after fullscreen close transfers content back to compose
|
|
209
|
+
* Call this immediately after `setContent` with JSON that may contain pending
|
|
210
|
+
* placeholders (e.g. after fullscreen close transfers content back to compose).
|
|
116
211
|
*
|
|
117
|
-
* @param editor - The TipTap editor that now contains the content
|
|
212
|
+
* @param editor - The TipTap editor that now contains the placeholder content
|
|
118
213
|
* @returns Array of promises that resolve when each adopted upload completes
|
|
119
214
|
*/
|
|
120
215
|
export function adoptPendingInlineImageUploads(
|
|
@@ -126,7 +221,7 @@ export function adoptPendingInlineImageUploads(
|
|
|
126
221
|
doc.descendants((node) => {
|
|
127
222
|
if (node.type.name !== "image") return;
|
|
128
223
|
const src = node.attrs.src as string;
|
|
129
|
-
if (
|
|
224
|
+
if (typeof src !== "string") return;
|
|
130
225
|
|
|
131
226
|
const uploaded = inflightUploads.get(src);
|
|
132
227
|
if (!uploaded) return;
|
|
@@ -137,14 +232,17 @@ export function adoptPendingInlineImageUploads(
|
|
|
137
232
|
inflightUploads.delete(src);
|
|
138
233
|
adoptedUploads.set(src, uploaded);
|
|
139
234
|
|
|
235
|
+
// Blob placeholders own an object URL and are removed on failure; remote/
|
|
236
|
+
// data rehost placeholders have neither (keep the node, nothing to revoke).
|
|
237
|
+
const isBlob = src.startsWith("blob:");
|
|
140
238
|
const promise = uploaded
|
|
141
239
|
.then(
|
|
142
240
|
(realUrl) => replaceInlineImage(editor, src, realUrl),
|
|
143
|
-
() =>
|
|
241
|
+
() => settlePlaceholder(editor, src, null),
|
|
144
242
|
)
|
|
145
243
|
.finally(() => {
|
|
146
244
|
adoptedUploads.delete(src);
|
|
147
|
-
URL.revokeObjectURL(src);
|
|
245
|
+
if (isBlob) URL.revokeObjectURL(src);
|
|
148
246
|
});
|
|
149
247
|
adopted.push(promise);
|
|
150
248
|
});
|
|
@@ -153,80 +251,106 @@ export function adoptPendingInlineImageUploads(
|
|
|
153
251
|
}
|
|
154
252
|
|
|
155
253
|
/**
|
|
156
|
-
* Resolve all
|
|
254
|
+
* Resolve all pending inline image placeholders in a TipTap JSON document.
|
|
157
255
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
256
|
+
* Covers both placeholder kinds tracked in the registries: `blob:` URLs from
|
|
257
|
+
* the insert/upload flow and remote/`data:` URLs from the paste-rehost flow.
|
|
258
|
+
* Waits for the pending work and returns a new JSON tree with stored URLs.
|
|
259
|
+
* Unresolved `blob:` placeholders (upload failed) are removed; unresolved
|
|
260
|
+
* remote/`data:` placeholders are kept with their original src (rehost is
|
|
261
|
+
* best-effort, so the original image still shows).
|
|
160
262
|
*
|
|
161
263
|
* Used by the submit bridge to finalize content before posting.
|
|
162
264
|
*
|
|
163
|
-
* @param json - TipTap JSON document that may contain
|
|
164
|
-
* @returns Resolved JSON with
|
|
265
|
+
* @param json - TipTap JSON document that may contain placeholder image srcs
|
|
266
|
+
* @returns Resolved JSON with placeholder srcs replaced (or kept/removed)
|
|
165
267
|
*/
|
|
166
268
|
export async function resolveInlineImageUrls(
|
|
167
269
|
json: JSONContent | null,
|
|
168
270
|
): Promise<JSONContent | null> {
|
|
169
271
|
if (!json) return json;
|
|
170
272
|
|
|
171
|
-
// Collect
|
|
172
|
-
const
|
|
173
|
-
|
|
273
|
+
// Collect every placeholder src present in the registries + its promise.
|
|
274
|
+
const placeholders = new Map<string, Promise<string>>();
|
|
275
|
+
collectPlaceholderUrls(json, placeholders);
|
|
174
276
|
|
|
175
|
-
if (
|
|
277
|
+
if (placeholders.size === 0) return json;
|
|
176
278
|
|
|
177
|
-
// Wait for all
|
|
178
|
-
const
|
|
279
|
+
// Wait for all pending work; record success (URL) or failure (null) per src.
|
|
280
|
+
const outcomes = new Map<string, string | null>();
|
|
179
281
|
await Promise.allSettled(
|
|
180
|
-
Array.from(
|
|
181
|
-
|
|
182
|
-
|
|
282
|
+
Array.from(placeholders.entries()).map(async ([src, promise]) => {
|
|
283
|
+
try {
|
|
284
|
+
outcomes.set(src, await promise);
|
|
285
|
+
} catch {
|
|
286
|
+
outcomes.set(src, null);
|
|
287
|
+
}
|
|
183
288
|
}),
|
|
184
289
|
);
|
|
185
290
|
|
|
186
|
-
return
|
|
291
|
+
return applyPlaceholderOutcomes(json, outcomes);
|
|
187
292
|
}
|
|
188
293
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Whether a TipTap JSON document still references inline image placeholders that
|
|
296
|
+
* are pending upload/rehost. Used to decide the "uploading" toast and whether to
|
|
297
|
+
* run {@link resolveInlineImageUrls} before submit.
|
|
298
|
+
*
|
|
299
|
+
* @param json - TipTap JSON document to inspect
|
|
300
|
+
* @returns True if any image src is a pending placeholder
|
|
301
|
+
*/
|
|
302
|
+
export function hasPendingInlineImagePlaceholders(
|
|
303
|
+
json: JSONContent | null,
|
|
304
|
+
): boolean {
|
|
305
|
+
if (!json) return false;
|
|
306
|
+
const placeholders = new Map<string, Promise<string>>();
|
|
307
|
+
collectPlaceholderUrls(json, placeholders);
|
|
308
|
+
return placeholders.size > 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function collectPlaceholderUrls(
|
|
312
|
+
node: JSONContent,
|
|
313
|
+
out: Map<string, Promise<string>>,
|
|
314
|
+
) {
|
|
315
|
+
if (node.type === "image" && typeof node.attrs?.src === "string") {
|
|
316
|
+
const src = node.attrs.src;
|
|
317
|
+
const promise = inflightUploads.get(src) ?? adoptedUploads.get(src);
|
|
197
318
|
if (promise) {
|
|
198
|
-
out.set(
|
|
319
|
+
out.set(src, promise);
|
|
199
320
|
}
|
|
200
321
|
}
|
|
201
322
|
if (node.content) {
|
|
202
323
|
for (const child of node.content) {
|
|
203
|
-
|
|
324
|
+
collectPlaceholderUrls(child, out);
|
|
204
325
|
}
|
|
205
326
|
}
|
|
206
327
|
}
|
|
207
328
|
|
|
208
|
-
function
|
|
329
|
+
function applyPlaceholderOutcomes(
|
|
209
330
|
node: JSONContent,
|
|
210
|
-
|
|
331
|
+
outcomes: Map<string, string | null>,
|
|
211
332
|
): JSONContent {
|
|
212
|
-
// Remove image nodes with unresolved blob URLs (upload failed or orphaned)
|
|
213
333
|
if (
|
|
214
334
|
node.type === "image" &&
|
|
215
335
|
typeof node.attrs?.src === "string" &&
|
|
216
|
-
node.attrs.src
|
|
336
|
+
outcomes.has(node.attrs.src)
|
|
217
337
|
) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
338
|
+
const src = node.attrs.src;
|
|
339
|
+
const realUrl = outcomes.get(src);
|
|
340
|
+
if (realUrl) {
|
|
341
|
+
return { ...node, attrs: { ...node.attrs, src: realUrl } };
|
|
342
|
+
}
|
|
343
|
+
// Unresolved: drop blob placeholders (orphaned upload), keep remote/data.
|
|
344
|
+
if (src.startsWith("blob:")) {
|
|
221
345
|
return { type: "__removed__" };
|
|
222
346
|
}
|
|
223
|
-
return
|
|
347
|
+
return node;
|
|
224
348
|
}
|
|
225
349
|
|
|
226
350
|
if (!node.content) return node;
|
|
227
351
|
|
|
228
352
|
const newContent = node.content
|
|
229
|
-
.map((child) =>
|
|
353
|
+
.map((child) => applyPlaceholderOutcomes(child, outcomes))
|
|
230
354
|
.filter((child) => child.type !== "__removed__");
|
|
231
355
|
|
|
232
356
|
return newContent === node.content ? node : { ...node, content: newContent };
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Extension } from "@tiptap/core";
|
|
13
|
-
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
13
|
+
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
14
14
|
import type { EditorState } from "@tiptap/pm/state";
|
|
15
15
|
import type { EditorView } from "@tiptap/pm/view";
|
|
16
16
|
import {
|
|
@@ -331,9 +331,71 @@ export const LinkToolbar = Extension.create({
|
|
|
331
331
|
suppressNextUpdate = true;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Open the passive link popover when a link is clicked or tapped.
|
|
336
|
+
*
|
|
337
|
+
* The popover is otherwise shown only as a side effect of a collapsed
|
|
338
|
+
* caret landing inside a link (see the plugin `update` below). A mouse
|
|
339
|
+
* click reliably drops that caret, but a touch tap often does not — it may
|
|
340
|
+
* leave the selection unchanged or select a word — so on mobile the popover
|
|
341
|
+
* never appeared. Here we read the tapped position directly and force a
|
|
342
|
+
* collapsed caret inside the link, which works for both pointer types.
|
|
343
|
+
*/
|
|
344
|
+
function handleLinkClick(view: EditorView, event: MouseEvent) {
|
|
345
|
+
if (event.button !== 0) return;
|
|
346
|
+
const target = event.target as HTMLElement | null;
|
|
347
|
+
const anchor = target?.closest("a");
|
|
348
|
+
if (!anchor || !view.dom.contains(anchor)) return;
|
|
349
|
+
|
|
350
|
+
const linkType = view.state.schema.marks.link;
|
|
351
|
+
if (!linkType) return;
|
|
352
|
+
|
|
353
|
+
// Don't disturb an active range selection (e.g. drag-selecting link
|
|
354
|
+
// text) — that case is owned by the bubble menu.
|
|
355
|
+
if (!view.state.selection.empty) return;
|
|
356
|
+
|
|
357
|
+
// When the click already dropped a caret inside a link (the desktop
|
|
358
|
+
// path), keep it where the user clicked. On touch the tap often doesn't
|
|
359
|
+
// move the caret into the link, so derive a position from the anchor
|
|
360
|
+
// element itself — targeting this exact link regardless of where the
|
|
361
|
+
// user tapped.
|
|
362
|
+
const cur = view.state.selection.from;
|
|
363
|
+
const hasLink = (p: number) =>
|
|
364
|
+
view.state.doc
|
|
365
|
+
.resolve(p)
|
|
366
|
+
.marks()
|
|
367
|
+
.some((m) => m.type === linkType);
|
|
368
|
+
|
|
369
|
+
let pos = cur;
|
|
370
|
+
if (!hasLink(cur)) {
|
|
371
|
+
try {
|
|
372
|
+
pos = view.posAtDOM(anchor, 0) + 1;
|
|
373
|
+
} catch {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (pos < 0 || pos > view.state.doc.content.size) return;
|
|
377
|
+
if (!hasLink(pos)) return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Re-show even if the user previously dismissed it, then (re)place a
|
|
381
|
+
// collapsed caret in the link so the passive popover flow picks it up.
|
|
382
|
+
suppressAutoShow = false;
|
|
383
|
+
view.dispatch(
|
|
384
|
+
view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
334
388
|
return [
|
|
335
389
|
new Plugin({
|
|
336
390
|
key: linkToolbarKey,
|
|
391
|
+
props: {
|
|
392
|
+
handleDOMEvents: {
|
|
393
|
+
click: (view, event) => {
|
|
394
|
+
handleLinkClick(view, event as MouseEvent);
|
|
395
|
+
return false;
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
337
399
|
view(editorView) {
|
|
338
400
|
createElements();
|
|
339
401
|
const dialog = editorView.dom.closest("dialog");
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehost-on-paste plugin for inline images.
|
|
3
|
+
*
|
|
4
|
+
* When an author pastes article HTML, its `<img>` tags become image nodes whose
|
|
5
|
+
* `src` still points at the original remote URL (or is an inline `data:` URL).
|
|
6
|
+
* This extension detects those nodes after a paste and asks the host to rehost
|
|
7
|
+
* them into the site's own storage, swapping the node's `src` to the stored URL.
|
|
8
|
+
*
|
|
9
|
+
* It only schedules work — the actual upload/swap happens in the host callback
|
|
10
|
+
* (see `jant-compose-editor`), tracked by the shared inline-image registry so
|
|
11
|
+
* submit waits for it. The remote URL stays visible as an instant preview until
|
|
12
|
+
* the swap completes; on failure the node keeps its original src.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Extension } from "@tiptap/core";
|
|
16
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
17
|
+
|
|
18
|
+
export interface RehostImagesOptions {
|
|
19
|
+
/** Returns true when an image src should be rehosted (remote/data, not ours). */
|
|
20
|
+
shouldRehost?: (src: string) => boolean;
|
|
21
|
+
/** Starts rehosting the given src. Must call clearRehostInFlight when settled. */
|
|
22
|
+
rehost?: (src: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generous per-paste cap. Beyond this, extra images are left as external links
|
|
27
|
+
* (and logged) rather than firing an unbounded number of fetches at once.
|
|
28
|
+
*/
|
|
29
|
+
const REHOST_MAX_PER_DOC = 50;
|
|
30
|
+
|
|
31
|
+
const rehostImagesKey = new PluginKey("jantRehostImages");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Srcs currently being rehosted, deduped so repeated transactions (e.g. typing
|
|
35
|
+
* after a paste, or the src-swap itself) don't re-trigger the same work.
|
|
36
|
+
*/
|
|
37
|
+
const rehostInFlight = new Set<string>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Release a src once its rehost has settled, so an identical URL pasted later
|
|
41
|
+
* can be rehosted again.
|
|
42
|
+
*
|
|
43
|
+
* @param src - The placeholder src that was being rehosted
|
|
44
|
+
*/
|
|
45
|
+
export function clearRehostInFlight(src: string): void {
|
|
46
|
+
rehostInFlight.delete(src);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const RehostImages = Extension.create<RehostImagesOptions>({
|
|
50
|
+
name: "rehostImages",
|
|
51
|
+
|
|
52
|
+
addOptions() {
|
|
53
|
+
return {
|
|
54
|
+
shouldRehost: undefined,
|
|
55
|
+
rehost: undefined,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
addProseMirrorPlugins() {
|
|
60
|
+
const options = this.options;
|
|
61
|
+
return [
|
|
62
|
+
new Plugin({
|
|
63
|
+
key: rehostImagesKey,
|
|
64
|
+
appendTransaction(transactions, _oldState, newState) {
|
|
65
|
+
if (!transactions.some((tr) => tr.docChanged)) return null;
|
|
66
|
+
const { shouldRehost, rehost } = options;
|
|
67
|
+
if (!shouldRehost || !rehost) return null;
|
|
68
|
+
|
|
69
|
+
const candidates: string[] = [];
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
newState.doc.descendants((node) => {
|
|
72
|
+
if (node.type.name !== "image") return;
|
|
73
|
+
const src = node.attrs.src;
|
|
74
|
+
if (typeof src !== "string" || !src) return;
|
|
75
|
+
if (seen.has(src) || rehostInFlight.has(src)) return;
|
|
76
|
+
if (!shouldRehost(src)) return;
|
|
77
|
+
seen.add(src);
|
|
78
|
+
candidates.push(src);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (candidates.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
const accepted = candidates.slice(0, REHOST_MAX_PER_DOC);
|
|
84
|
+
const dropped = candidates.length - accepted.length;
|
|
85
|
+
if (dropped > 0) {
|
|
86
|
+
// eslint-disable-next-line no-console -- surface a silently-skipped cap
|
|
87
|
+
console.warn(
|
|
88
|
+
`[jant] ${dropped} pasted image(s) left as external links — ` +
|
|
89
|
+
`per-paste rehost cap of ${REHOST_MAX_PER_DOC} reached.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const src of accepted) {
|
|
94
|
+
rehostInFlight.add(src);
|
|
95
|
+
queueMicrotask(() => rehost(src));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// We only schedule side effects; the doc isn't modified here.
|
|
99
|
+
return null;
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
];
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -33,6 +33,11 @@ msgstr "{count, plural, one {# more post} other {# more posts}}"
|
|
|
33
33
|
msgid "{count, plural, one {Found # result} other {Found # results}}"
|
|
34
34
|
msgstr "{count, plural, one {Found # result} other {Found # results}}"
|
|
35
35
|
|
|
36
|
+
#. @context: Toast when several pasted remote images couldn't be rehosted; {count} is the number of images
|
|
37
|
+
#: src/ui/compose/ComposeDialog.tsx
|
|
38
|
+
msgid "{count} images couldn't be saved to your library — their original links were kept."
|
|
39
|
+
msgstr "{count} images couldn't be saved to your library — their original links were kept."
|
|
40
|
+
|
|
36
41
|
#. @context: Placeholder for the custom collections link URL
|
|
37
42
|
#: src/ui/shared/collection-management-labels.ts
|
|
38
43
|
msgid "/archive?format=quote or https://example.com"
|
|
@@ -193,6 +198,11 @@ msgstr "All years"
|
|
|
193
198
|
msgid "An abstract editorial layout in warm paper colors"
|
|
194
199
|
msgstr "An abstract editorial layout in warm paper colors"
|
|
195
200
|
|
|
201
|
+
#. @context: Toast when a single pasted remote image couldn't be rehosted (e.g. blocked by the source's hotlink protection)
|
|
202
|
+
#: src/ui/compose/ComposeDialog.tsx
|
|
203
|
+
msgid "An image couldn't be saved to your library — its original link was kept."
|
|
204
|
+
msgstr "An image couldn't be saved to your library — its original link was kept."
|
|
205
|
+
|
|
196
206
|
#. @context: Sample link post body suffix on the theme sample page
|
|
197
207
|
#: src/ui/pages/ThemeSamplePage.tsx
|
|
198
208
|
msgid "and checking whether the accent is guiding attention or pulling too hard."
|