@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.
Files changed (69) hide show
  1. package/dist/{app-C-jxWmAV.js → app-CpmficmQ.js} +912 -423
  2. package/dist/app-DqKkZenB.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BhHHVvSY.css +2 -0
  5. package/dist/client/_assets/{client-DWy1LEEk.js → client-Dd9U383b.js} +1 -1
  6. package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-DkpSdIDz.js} +101 -99
  7. package/dist/{export-C2DIB7mm.js → export-Ba7NJImL.js} +93 -93
  8. package/dist/{github-sync-7XQ5ZM6z.js → github-sync-BD4w2m8-.js} +2 -2
  9. package/dist/{github-sync-BEFCfLKK.js → github-sync-Cb4_6_i7.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
  16. package/src/client/components/compose-types.ts +4 -0
  17. package/src/client/components/jant-compose-editor.ts +111 -0
  18. package/src/client/components/jant-settings-general.ts +18 -3
  19. package/src/client/components/settings-types.ts +2 -0
  20. package/src/client/compose-bridge.ts +25 -8
  21. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  22. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  23. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  24. package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
  25. package/src/client/tiptap/create-editor.ts +3 -0
  26. package/src/client/tiptap/extensions.ts +4 -0
  27. package/src/client/tiptap/inline-image-upload.ts +174 -50
  28. package/src/client/tiptap/link-toolbar.ts +63 -1
  29. package/src/client/tiptap/rehost-images.ts +104 -0
  30. package/src/i18n/locales/public/en.po +10 -0
  31. package/src/i18n/locales/public/en.ts +1 -1
  32. package/src/i18n/locales/public/zh-Hans.po +10 -0
  33. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  34. package/src/i18n/locales/public/zh-Hant.po +10 -0
  35. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  36. package/src/i18n/locales/settings/en.po +258 -18
  37. package/src/i18n/locales/settings/en.ts +1 -1
  38. package/src/i18n/locales/settings/zh-Hans.po +258 -18
  39. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  40. package/src/i18n/locales/settings/zh-Hant.po +258 -18
  41. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  42. package/src/lib/__tests__/feed.test.ts +5 -1
  43. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  44. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  45. package/src/lib/feed.ts +6 -3
  46. package/src/lib/upload.ts +111 -0
  47. package/src/lib/url-fetch.ts +263 -0
  48. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  49. package/src/routes/api/uploads.ts +52 -0
  50. package/src/routes/dash/settings.tsx +7 -2
  51. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  52. package/src/routes/feed/feed.ts +37 -28
  53. package/src/routes/pages/featured.tsx +17 -0
  54. package/src/routes/pages/latest.tsx +25 -0
  55. package/src/services/__tests__/media.test.ts +168 -1
  56. package/src/services/media.ts +111 -0
  57. package/src/services/post.ts +1 -1
  58. package/src/styles/tokens.css +15 -0
  59. package/src/styles/ui.css +45 -2
  60. package/src/ui/__tests__/color-themes.test.ts +2 -2
  61. package/src/ui/color-themes.ts +32 -0
  62. package/src/ui/compose/ComposeDialog.tsx +16 -0
  63. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  64. package/src/ui/dash/settings/GeneralContent.tsx +16 -0
  65. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  66. package/src/ui/layouts/BaseLayout.tsx +14 -2
  67. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
  68. package/dist/app-DqHzOwL5.js +0 -6
  69. 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, blobUrl: string, realUrl: string) {
30
+ function replaceInlineImage(editor: Editor, fromUrl: string, realUrl: string) {
30
31
  if (editor.isDestroyed) return;
31
32
  const { doc } = editor.state;
32
- let replaced = false;
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 (replaced || node.type.name !== "image" || node.attrs.src !== blobUrl) {
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
- replaceInlineImage(editor, placeholderUrl, realUrl);
119
+ settlePlaceholder(editor, placeholderUrl, realUrl);
97
120
  } catch {
98
- removeInlineImage(editor, placeholderUrl);
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
- * Adopt in-flight inline image uploads into a new editor instance.
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 blob: image URLs that match pending uploads.
111
- * For each match, takes ownership from the original editor and sets up
112
- * replacement/removal watchers on the new editor.
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 blob: URLs
115
- * (e.g. after fullscreen close transfers content back to compose editor).
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 with blob: URLs
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 (!src?.startsWith("blob:")) return;
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
- () => removeInlineImage(editor, src),
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 blob: inline image URLs in a TipTap JSON document.
254
+ * Resolve all pending inline image placeholders in a TipTap JSON document.
157
255
  *
158
- * Waits for pending uploads and returns a new JSON tree with real URLs.
159
- * Failed or unresolvable blob: images are removed from the content.
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 blob: image URLs
164
- * @returns Resolved JSON with all blob: URLs replaced or removed
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 all blob URLs and their upload promises
172
- const blobUrls = new Map<string, Promise<string>>();
173
- collectBlobUrls(json, blobUrls);
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 (blobUrls.size === 0) return json;
277
+ if (placeholders.size === 0) return json;
176
278
 
177
- // Wait for all uploads to settle
178
- const resolved = new Map<string, string>();
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(blobUrls.entries()).map(async ([blobUrl, promise]) => {
181
- const realUrl = await promise;
182
- resolved.set(blobUrl, realUrl);
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 replaceBlobUrlsInJson(json, resolved);
291
+ return applyPlaceholderOutcomes(json, outcomes);
187
292
  }
188
293
 
189
- function collectBlobUrls(node: JSONContent, out: Map<string, Promise<string>>) {
190
- if (
191
- node.type === "image" &&
192
- typeof node.attrs?.src === "string" &&
193
- node.attrs.src.startsWith("blob:")
194
- ) {
195
- const promise =
196
- inflightUploads.get(node.attrs.src) ?? adoptedUploads.get(node.attrs.src);
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(node.attrs.src, promise);
319
+ out.set(src, promise);
199
320
  }
200
321
  }
201
322
  if (node.content) {
202
323
  for (const child of node.content) {
203
- collectBlobUrls(child, out);
324
+ collectPlaceholderUrls(child, out);
204
325
  }
205
326
  }
206
327
  }
207
328
 
208
- function replaceBlobUrlsInJson(
329
+ function applyPlaceholderOutcomes(
209
330
  node: JSONContent,
210
- resolved: Map<string, string>,
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.startsWith("blob:")
336
+ outcomes.has(node.attrs.src)
217
337
  ) {
218
- const realUrl = resolved.get(node.attrs.src);
219
- if (!realUrl) {
220
- // Signal removal by returning a marker — handled by parent
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 { ...node, attrs: { ...node.attrs, src: realUrl } };
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) => replaceBlobUrlsInJson(child, resolved))
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."