@jant/core 0.6.10 → 0.6.12

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 (92) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
  8. package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
  9. package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/compose-types.ts +4 -0
  25. package/src/client/components/jant-command-palette.ts +67 -2
  26. package/src/client/components/jant-compose-dialog.ts +8 -2
  27. package/src/client/components/jant-compose-editor.ts +111 -0
  28. package/src/client/components/jant-nav-manager.ts +142 -2
  29. package/src/client/components/jant-settings-general.ts +56 -0
  30. package/src/client/components/nav-manager-types.ts +14 -0
  31. package/src/client/components/settings-types.ts +30 -0
  32. package/src/client/compose-bridge.ts +25 -8
  33. package/src/client/compose-launch.ts +30 -0
  34. package/src/client/compose-shortcuts.ts +46 -3
  35. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  36. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  37. package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
  38. package/src/client/tiptap/create-editor.ts +3 -0
  39. package/src/client/tiptap/extensions.ts +4 -0
  40. package/src/client/tiptap/inline-image-upload.ts +174 -50
  41. package/src/client/tiptap/rehost-images.ts +108 -0
  42. package/src/i18n/__tests__/fallback.test.ts +24 -0
  43. package/src/i18n/locales/public/en.po +10 -0
  44. package/src/i18n/locales/public/en.ts +1 -1
  45. package/src/i18n/locales/public/zh-Hans.po +10 -0
  46. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/public/zh-Hant.po +10 -0
  48. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  49. package/src/i18n/locales/settings/en.po +54 -0
  50. package/src/i18n/locales/settings/en.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  52. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  54. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  55. package/src/lib/__tests__/feed.test.ts +96 -10
  56. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  57. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  58. package/src/lib/feed.ts +63 -3
  59. package/src/lib/post-display.ts +14 -8
  60. package/src/lib/upload.ts +111 -0
  61. package/src/lib/url-fetch.ts +263 -0
  62. package/src/preset.css +60 -18
  63. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  64. package/src/routes/api/uploads.ts +52 -0
  65. package/src/routes/dash/settings.tsx +41 -2
  66. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  67. package/src/routes/pages/page.tsx +44 -12
  68. package/src/services/__tests__/about-page.test.ts +116 -0
  69. package/src/services/__tests__/media.test.ts +168 -1
  70. package/src/services/__tests__/navigation.test.ts +190 -1
  71. package/src/services/__tests__/post-timeline.test.ts +119 -2
  72. package/src/services/about-page.ts +143 -0
  73. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  74. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  75. package/src/services/export-theme/styles/main.css +46 -14
  76. package/src/services/index.ts +27 -17
  77. package/src/services/media.ts +111 -0
  78. package/src/services/navigation.ts +155 -1
  79. package/src/services/post.ts +24 -7
  80. package/src/styles/components.css +20 -0
  81. package/src/styles/tokens.css +26 -8
  82. package/src/styles/ui.css +149 -7
  83. package/src/types/views.ts +13 -0
  84. package/src/ui/compose/ComposeDialog.tsx +16 -0
  85. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  86. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  87. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  88. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  89. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  90. package/src/ui/layouts/BaseLayout.tsx +12 -0
  91. package/dist/app-D24n0DoH.js +0 -6
  92. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createTiptapEditor } from "../create-editor.js";
5
+ import type { Editor } from "@tiptap/core";
6
+
7
+ const editors: Editor[] = [];
8
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
9
+
10
+ afterEach(() => {
11
+ while (editors.length > 0) editors.pop()?.destroy();
12
+ document.body.innerHTML = "";
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ function dispatchPaste(editor: Editor, html: string, text = "") {
17
+ const event = new Event("paste", {
18
+ bubbles: true,
19
+ cancelable: true,
20
+ }) as Event & { clipboardData: unknown };
21
+ event.clipboardData = {
22
+ getData: (type: string) =>
23
+ type === "text/html" ? html : type === "text/plain" ? text : "",
24
+ files: [],
25
+ items: [],
26
+ types: ["text/html", "text/plain"],
27
+ };
28
+ editor.view.dom.dispatchEvent(event);
29
+ }
30
+
31
+ function imageSrcs(editor: Editor): string[] {
32
+ const out: string[] = [];
33
+ editor.state.doc.descendants((n) => {
34
+ if (n.type.name === "image") out.push(n.attrs.src as string);
35
+ });
36
+ return out;
37
+ }
38
+
39
+ describe("paste → rehost end to end", () => {
40
+ it("turns pasted article <img> into image nodes and triggers rehost", async () => {
41
+ const rehost = vi.fn();
42
+ const el = document.createElement("div");
43
+ document.body.appendChild(el);
44
+ const editor = createTiptapEditor({
45
+ element: el,
46
+ rehostImages: { shouldRehost: () => true, rehost },
47
+ });
48
+ editors.push(editor);
49
+ editor.commands.focus();
50
+
51
+ const html =
52
+ "<p>正文段落一</p>" +
53
+ '<p><img src="https://img1.doubanio.com/view/photo/l/public/p1.jpg"></p>' +
54
+ "<p>正文段落二</p>";
55
+ dispatchPaste(editor, html);
56
+ await flush();
57
+
58
+ expect(imageSrcs(editor)).toContain(
59
+ "https://img1.doubanio.com/view/photo/l/public/p1.jpg",
60
+ );
61
+ expect(rehost).toHaveBeenCalledWith(
62
+ "https://img1.doubanio.com/view/photo/l/public/p1.jpg",
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,192 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageNode } from "../image-node.js";
7
+ import { RehostImages, clearRehostInFlight } from "../rehost-images.js";
8
+
9
+ const editors: Editor[] = [];
10
+ const usedSrcs: string[] = [];
11
+
12
+ /** Mirrors the predicate the compose editor passes to the extension. */
13
+ function shouldRehost(src: string): boolean {
14
+ if (src.startsWith("data:")) return true;
15
+ if (!/^https?:\/\//i.test(src)) return false;
16
+ let origin: string;
17
+ try {
18
+ origin = new URL(src).origin;
19
+ } catch {
20
+ return false;
21
+ }
22
+ if (origin === window.location.origin) return false;
23
+ const mediaBase = document.documentElement.dataset.mediaBase;
24
+ if (mediaBase && src.startsWith(mediaBase)) return false;
25
+ return true;
26
+ }
27
+
28
+ function createEditor(rehost: (src: string) => void): Editor {
29
+ const element = document.createElement("div");
30
+ document.body.appendChild(element);
31
+ const editor = new Editor({
32
+ element,
33
+ extensions: [
34
+ StarterKit.configure({
35
+ heading: { levels: [1, 2, 3] },
36
+ link: { openOnClick: false, autolink: false },
37
+ }),
38
+ ImageNode,
39
+ RehostImages.configure({ shouldRehost, rehost }),
40
+ ],
41
+ content: "<p></p>",
42
+ });
43
+ editors.push(editor);
44
+ return editor;
45
+ }
46
+
47
+ /** Track a src so the module-level in-flight set is reset between tests. */
48
+ function track(src: string): string {
49
+ usedSrcs.push(src);
50
+ return src;
51
+ }
52
+
53
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
54
+
55
+ function dispatchPaste(editor: Editor, html: string, text = "") {
56
+ const event = new Event("paste", {
57
+ bubbles: true,
58
+ cancelable: true,
59
+ }) as Event & { clipboardData: unknown };
60
+ event.clipboardData = {
61
+ getData: (type: string) =>
62
+ type === "text/html" ? html : type === "text/plain" ? text : "",
63
+ files: [],
64
+ items: [],
65
+ types: ["text/html", "text/plain"],
66
+ };
67
+ editor.commands.focus();
68
+ editor.view.dom.dispatchEvent(event);
69
+ }
70
+
71
+ afterEach(() => {
72
+ while (editors.length > 0) editors.pop()?.destroy();
73
+ for (const src of usedSrcs) clearRehostInFlight(src);
74
+ usedSrcs.length = 0;
75
+ document.body.innerHTML = "";
76
+ delete document.documentElement.dataset.mediaBase;
77
+ vi.restoreAllMocks();
78
+ });
79
+
80
+ describe("RehostImages extension", () => {
81
+ it("fires rehost once for a pasted remote image", async () => {
82
+ const rehost = vi.fn();
83
+ const editor = createEditor(rehost);
84
+ const src = track("https://ext.example/a-remote.png");
85
+
86
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
87
+ await flush();
88
+
89
+ expect(rehost).toHaveBeenCalledTimes(1);
90
+ expect(rehost).toHaveBeenCalledWith(src);
91
+ });
92
+
93
+ it("fires rehost for a data: URL", async () => {
94
+ const rehost = vi.fn();
95
+ const editor = createEditor(rehost);
96
+ const src = track("data:image/png;base64,AAAAb");
97
+
98
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
99
+ await flush();
100
+
101
+ expect(rehost).toHaveBeenCalledWith(src);
102
+ });
103
+
104
+ it("skips same-origin, relative, blob, and media-base images", async () => {
105
+ const rehost = vi.fn();
106
+ document.documentElement.dataset.mediaBase = "https://cdn.mysite.test";
107
+ const editor = createEditor(rehost);
108
+ const sameOrigin = track(`${window.location.origin}/m/x.png`);
109
+ const relative = track("/relative/y.png");
110
+ const blob = track("blob:abc-123");
111
+ const mediaBase = track("https://cdn.mysite.test/z.png");
112
+
113
+ dispatchPaste(
114
+ editor,
115
+ `<p><img src="${sameOrigin}"><img src="${relative}"><img src="${blob}"><img src="${mediaBase}"></p>`,
116
+ );
117
+ await flush();
118
+
119
+ expect(rehost).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it("dedupes identical remote srcs in one document", async () => {
123
+ const rehost = vi.fn();
124
+ const editor = createEditor(rehost);
125
+ const src = track("https://ext.example/dupe.png");
126
+
127
+ dispatchPaste(editor, `<p><img src="${src}"><img src="${src}"></p>`);
128
+ await flush();
129
+
130
+ expect(rehost).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("does not rehost remote images loaded through setContent", async () => {
134
+ const rehost = vi.fn();
135
+ const editor = createEditor(rehost);
136
+ const src = track("https://ext.example/saved-external.png");
137
+
138
+ editor.commands.setContent({
139
+ type: "doc",
140
+ content: [
141
+ { type: "paragraph" },
142
+ {
143
+ type: "image",
144
+ attrs: { src },
145
+ },
146
+ ],
147
+ });
148
+ await flush();
149
+
150
+ expect(rehost).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("does not retry a failed paste rehost on later edits", async () => {
154
+ const rehost = vi.fn();
155
+ const editor = createEditor(rehost);
156
+ const src = track("https://ext.example/blocked.png");
157
+
158
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
159
+ await flush();
160
+ expect(rehost).toHaveBeenCalledTimes(1);
161
+
162
+ clearRehostInFlight(src);
163
+ editor.commands.insertContent("later edit");
164
+ await flush();
165
+
166
+ expect(rehost).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it("does not re-trigger after the src is swapped to a local URL", async () => {
170
+ const rehost = vi.fn();
171
+ const editor = createEditor(rehost);
172
+ const src = track("https://ext.example/swap.png");
173
+
174
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
175
+ await flush();
176
+ expect(rehost).toHaveBeenCalledTimes(1);
177
+
178
+ editor.commands.setContent({
179
+ type: "doc",
180
+ content: [
181
+ { type: "paragraph" },
182
+ {
183
+ type: "image",
184
+ attrs: { src: `${window.location.origin}/m/swap.png` },
185
+ },
186
+ ],
187
+ });
188
+ await flush();
189
+
190
+ expect(rehost).toHaveBeenCalledTimes(1);
191
+ });
192
+ });
@@ -11,6 +11,7 @@ import {
11
11
  } from "./extensions.js";
12
12
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
13
13
  import type { PasteMediaOptions } from "./paste-media.js";
14
+ import type { RehostImagesOptions } from "./rehost-images.js";
14
15
  import { normalizeFootnoteArtifacts } from "../../lib/footnotes.js";
15
16
  import { tiptapJsonToMarkdown } from "../../lib/tiptap-to-markdown.js";
16
17
  import { parseMarkdownDocument } from "../../lib/markdown-manager.js";
@@ -24,6 +25,7 @@ export interface CreateEditorOptions {
24
25
  onSelectionUpdate?: (selection: { from: number; to: number }) => void;
25
26
  toolbarMode?: FormattingToolbarMode;
26
27
  pasteMedia?: PasteMediaOptions;
28
+ rehostImages?: RehostImagesOptions;
27
29
  }
28
30
 
29
31
  /**
@@ -39,6 +41,7 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
39
41
  placeholder: options.placeholder,
40
42
  toolbarMode: options.toolbarMode,
41
43
  pasteMedia: options.pasteMedia,
44
+ rehostImages: options.rehostImages,
42
45
  }),
43
46
  content: options.content ?? undefined,
44
47
  editorProps: {
@@ -24,6 +24,8 @@ import { MoreBreak } from "./more-break.js";
24
24
  import { EmbedNode } from "./embed-node.js";
25
25
  import { HtmlBlockNode } from "./html-block-node.js";
26
26
  import { EmbedPaste } from "./embed-paste.js";
27
+ import { RehostImages } from "./rehost-images.js";
28
+ import type { RehostImagesOptions } from "./rehost-images.js";
27
29
  import { MarkdownClipboard } from "./markdown-clipboard.js";
28
30
  import {
29
31
  MARKDOWN_MARKED_OPTIONS,
@@ -34,6 +36,7 @@ export interface EditorExtensionOptions {
34
36
  placeholder?: string;
35
37
  toolbarMode?: FormattingToolbarMode;
36
38
  pasteMedia?: PasteMediaOptions;
39
+ rehostImages?: RehostImagesOptions;
37
40
  }
38
41
 
39
42
  /**
@@ -113,6 +116,7 @@ export function createEditorExtensions(
113
116
  SlashCommands,
114
117
  EmbedPaste,
115
118
  PasteMedia.configure(options.pasteMedia ?? {}),
119
+ RehostImages.configure(options.rehostImages ?? {}),
116
120
  BubbleMenu.configure({
117
121
  toolbarMode: options.toolbarMode ?? "default",
118
122
  }),
@@ -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 };