@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
@@ -43,6 +43,7 @@ export class JantSettingsGeneral extends LitElement {
43
43
  mainFeedUrl: { type: String, attribute: "main-feed-url" },
44
44
  latestFeedUrl: { type: String, attribute: "latest-feed-url" },
45
45
  featuredFeedUrl: { type: String, attribute: "featured-feed-url" },
46
+ archiveFeedUrl: { type: String, attribute: "archive-feed-url" },
46
47
 
47
48
  // Site group
48
49
  _siteName: { state: true },
@@ -90,6 +91,7 @@ export class JantSettingsGeneral extends LitElement {
90
91
  declare mainFeedUrl: string;
91
92
  declare latestFeedUrl: string;
92
93
  declare featuredFeedUrl: string;
94
+ declare archiveFeedUrl: string;
93
95
 
94
96
  // Site
95
97
  declare _siteName: string;
@@ -157,8 +159,9 @@ export class JantSettingsGeneral extends LitElement {
157
159
  this.siteDescriptionFallback = "";
158
160
  this.demoMode = false;
159
161
  this.mainFeedUrl = "/feed";
160
- this.latestFeedUrl = "/feed/latest";
161
- this.featuredFeedUrl = "/feed/featured";
162
+ this.latestFeedUrl = "/latest/feed";
163
+ this.featuredFeedUrl = "/featured/feed";
164
+ this.archiveFeedUrl = "/archive/feed";
162
165
 
163
166
  this._siteName = "";
164
167
  this._siteDescription = "";
@@ -718,10 +721,17 @@ export class JantSettingsGeneral extends LitElement {
718
721
  }
719
722
  }
720
723
 
721
- private _renderFeedInfoRow(label: string, value: string) {
724
+ private _renderFeedInfoRow(
725
+ label: string,
726
+ value: string,
727
+ description?: string,
728
+ ) {
722
729
  return html`
723
730
  <div class="flex min-w-0 flex-col gap-1">
724
731
  <p class="text-sm font-medium">${label}</p>
732
+ ${description
733
+ ? html`<p class="text-sm text-muted-foreground">${description}</p>`
734
+ : ""}
725
735
  <div class="relative">
726
736
  <input
727
737
  type="text"
@@ -967,6 +977,11 @@ export class JantSettingsGeneral extends LitElement {
967
977
  this.labels.featuredFeedUrl,
968
978
  this.featuredFeedUrl,
969
979
  )}
980
+ ${this._renderFeedInfoRow(
981
+ this.labels.archiveFeedUrl,
982
+ this.archiveFeedUrl,
983
+ this.labels.archiveFeedUrlHelp,
984
+ )}
970
985
  </div>
971
986
  </div>
972
987
 
@@ -35,6 +35,8 @@ export interface SettingsLabels {
35
35
  mainFeedUrl: string;
36
36
  latestFeedUrl: string;
37
37
  featuredFeedUrl: string;
38
+ archiveFeedUrl: string;
39
+ archiveFeedUrlHelp: string;
38
40
  latestFeedOption: string;
39
41
  latestFeedOptionDescription: string;
40
42
  featuredFeedOption: string;
@@ -32,7 +32,23 @@ import { uploadViaSession } from "./upload-session.js";
32
32
  import { publicPath } from "./runtime-paths.js";
33
33
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
34
34
  import { getMediaCategory } from "../lib/upload.js";
35
- import { resolveInlineImageUrls } from "./tiptap/inline-image-upload.js";
35
+ import {
36
+ resolveInlineImageUrls,
37
+ hasPendingInlineImagePlaceholders,
38
+ } from "./tiptap/inline-image-upload.js";
39
+
40
+ /**
41
+ * Whether a serialized post body still references inline image placeholders
42
+ * pending upload or paste-rehost. Drives the "uploading" toast and whether to
43
+ * resolve placeholders before submit.
44
+ */
45
+ function bodyHasPendingInline(body: string): boolean {
46
+ try {
47
+ return hasPendingInlineImagePlaceholders(JSON.parse(body));
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
36
52
 
37
53
  function getComposeEditorFromEventTarget(
38
54
  target: globalThis.EventTarget | null,
@@ -679,10 +695,10 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
679
695
  // Get labels for toast messages
680
696
  const labels = composeEl?.labels;
681
697
  const uploadingMsg = labels?.uploading ?? "Uploading...";
682
- const hasInlineBlobs = detail.threadPosts
683
- ? detail.threadPosts.some((p) => p.body.includes('"blob:'))
684
- : detail.body.includes('"blob:');
685
- const hasPending = detail.pendingAttachments.length > 0 || hasInlineBlobs;
698
+ const hasInlinePending = detail.threadPosts
699
+ ? detail.threadPosts.some((p) => bodyHasPendingInline(p.body))
700
+ : bodyHasPendingInline(detail.body);
701
+ const hasPending = detail.pendingAttachments.length > 0 || hasInlinePending;
686
702
  const publishedMsg = labels?.published ?? "Published!";
687
703
  const viewLabel = labels?.view ?? "View";
688
704
 
@@ -831,7 +847,7 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
831
847
  const resolvedPosts = await Promise.all(
832
848
  threadPosts.map(async (post) => {
833
849
  let body = post.body;
834
- if (body.includes('"blob:')) {
850
+ if (bodyHasPendingInline(body)) {
835
851
  try {
836
852
  const bodyJson = JSON.parse(body);
837
853
  const resolved = await resolveInlineImageUrls(bodyJson);
@@ -940,8 +956,9 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
940
956
  mediaClientIdMap,
941
957
  );
942
958
 
943
- // Resolve any blob: inline image URLs to real URLs before submitting
944
- if (hasInlineBlobs) {
959
+ // Resolve any pending inline image placeholders (blob upload + paste rehost)
960
+ // to their stored URLs before submitting.
961
+ if (hasInlinePending) {
945
962
  try {
946
963
  const bodyJson = JSON.parse(detail.body);
947
964
  const resolved = await resolveInlineImageUrls(bodyJson);
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor, type JSONContent } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageNode } from "../image-node.js";
7
+ import {
8
+ rehostInlineImage,
9
+ resolveInlineImageUrls,
10
+ hasPendingInlineImagePlaceholders,
11
+ } from "../inline-image-upload.js";
12
+
13
+ const editors: Editor[] = [];
14
+
15
+ function createEditor(): Editor {
16
+ const element = document.createElement("div");
17
+ document.body.appendChild(element);
18
+ const editor = new Editor({
19
+ element,
20
+ extensions: [
21
+ StarterKit.configure({
22
+ heading: { levels: [1, 2, 3] },
23
+ link: { openOnClick: false, autolink: false },
24
+ }),
25
+ ImageNode,
26
+ ],
27
+ content: "<p></p>",
28
+ });
29
+ editors.push(editor);
30
+ return editor;
31
+ }
32
+
33
+ function imageSrcs(editor: Editor): string[] {
34
+ const out: string[] = [];
35
+ editor.state.doc.descendants((node) => {
36
+ if (node.type.name === "image") out.push(node.attrs.src as string);
37
+ });
38
+ return out;
39
+ }
40
+
41
+ function jsonImageSrcs(node: JSONContent | null): string[] {
42
+ if (!node) return [];
43
+ const out: string[] = [];
44
+ const walk = (n: JSONContent) => {
45
+ if (n.type === "image" && typeof n.attrs?.src === "string") {
46
+ out.push(n.attrs.src);
47
+ }
48
+ for (const child of n.content ?? []) walk(child);
49
+ };
50
+ walk(node);
51
+ return out;
52
+ }
53
+
54
+ afterEach(() => {
55
+ while (editors.length > 0) editors.pop()?.destroy();
56
+ document.body.innerHTML = "";
57
+ });
58
+
59
+ describe("rehostInlineImage", () => {
60
+ it("swaps the node src on a successful rehost", async () => {
61
+ const editor = createEditor();
62
+ const src = "https://ext.example/keep1.png";
63
+ editor.commands.setImage({ src });
64
+
65
+ await rehostInlineImage(
66
+ editor,
67
+ src,
68
+ async () => "https://cdn.local/keep1.webp",
69
+ );
70
+
71
+ expect(imageSrcs(editor)).toEqual(["https://cdn.local/keep1.webp"]);
72
+ });
73
+
74
+ it("keeps a remote node's original src when the rehost fails", async () => {
75
+ const editor = createEditor();
76
+ const src = "https://ext.example/fail1.png";
77
+ editor.commands.setImage({ src });
78
+
79
+ await rehostInlineImage(editor, src, async () => {
80
+ throw new Error("boom");
81
+ });
82
+
83
+ expect(imageSrcs(editor)).toEqual([src]);
84
+ });
85
+
86
+ it("replaces every node sharing a deduped remote src", async () => {
87
+ const editor = createEditor();
88
+ const src = "https://ext.example/dupe.png";
89
+ editor.commands.setImage({ src });
90
+ editor.commands.setImage({ src });
91
+
92
+ await rehostInlineImage(
93
+ editor,
94
+ src,
95
+ async () => "https://cdn.local/dupe.webp",
96
+ );
97
+
98
+ expect(imageSrcs(editor)).toEqual([
99
+ "https://cdn.local/dupe.webp",
100
+ "https://cdn.local/dupe.webp",
101
+ ]);
102
+ });
103
+ });
104
+
105
+ describe("resolveInlineImageUrls + hasPendingInlineImagePlaceholders", () => {
106
+ it("tracks an in-flight rehost and resolves its placeholder to the stored URL", async () => {
107
+ const editor = createEditor();
108
+ const src = "https://ext.example/pending.png";
109
+ editor.commands.setImage({ src });
110
+
111
+ let release!: (url: string) => void;
112
+ const deferred = new Promise<string>((resolve) => {
113
+ release = resolve;
114
+ });
115
+ const rehostDone = rehostInlineImage(editor, src, () => deferred);
116
+
117
+ const json = editor.getJSON();
118
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(true);
119
+
120
+ const resolvePromise = resolveInlineImageUrls(json);
121
+ release("https://cdn.local/pending.webp");
122
+ const resolved = await resolvePromise;
123
+ await rehostDone;
124
+
125
+ expect(jsonImageSrcs(resolved)).toEqual(["https://cdn.local/pending.webp"]);
126
+ });
127
+
128
+ it("is a no-op for content with no pending placeholders", async () => {
129
+ const json: JSONContent = {
130
+ type: "doc",
131
+ content: [
132
+ {
133
+ type: "image",
134
+ attrs: { src: "https://cdn.local/already-stored.webp" },
135
+ },
136
+ ],
137
+ };
138
+
139
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(false);
140
+ const resolved = await resolveInlineImageUrls(json);
141
+ expect(resolved).toBe(json);
142
+ });
143
+ });
@@ -168,6 +168,47 @@ describe("LinkToolbar", () => {
168
168
  expect(linkMark).toBeUndefined();
169
169
  });
170
170
 
171
+ it("opens the popover when a link is clicked (tap on mobile)", async () => {
172
+ const editor = createEditor();
173
+
174
+ // Create a link, then move the caret off it and dismiss the popover.
175
+ editor.commands.setTextSelection({ from: 1, to: 6 });
176
+ editor.view.dom.dispatchEvent(new CustomEvent("tiptap:open-link-input"));
177
+ const urlInput = requireElement(
178
+ document.querySelector<HTMLInputElement>(".tiptap-link-input-field"),
179
+ "expected url field",
180
+ );
181
+ urlInput.value = "https://example.com";
182
+ urlInput.dispatchEvent(
183
+ new globalThis.KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
184
+ );
185
+ await Promise.resolve();
186
+
187
+ editor.commands.setTextSelection(10);
188
+ await Promise.resolve();
189
+ editor.commands.blur();
190
+ const popup = requireElement(
191
+ document.querySelector<HTMLElement>(".tiptap-link-input"),
192
+ "expected link popup",
193
+ );
194
+ popup.style.display = "none";
195
+
196
+ // Clicking the rendered link re-opens the popover without a prior caret
197
+ // move — this is the path that was broken on touch devices.
198
+ const anchor = requireElement(
199
+ editor.view.dom.querySelector<HTMLAnchorElement>("a"),
200
+ "expected rendered link",
201
+ );
202
+ anchor.dispatchEvent(
203
+ new globalThis.MouseEvent("click", { bubbles: true, button: 0 }),
204
+ );
205
+ await Promise.resolve();
206
+
207
+ expect(popup.style.display).toBe("flex");
208
+ expect(urlInput.value).toBe("https://example.com");
209
+ expect(editor.state.selection.empty).toBe(true);
210
+ });
211
+
171
212
  it("replaces the link text when the text field is edited", async () => {
172
213
  const editor = createEditor();
173
214
 
@@ -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,139 @@
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
+ afterEach(() => {
56
+ while (editors.length > 0) editors.pop()?.destroy();
57
+ for (const src of usedSrcs) clearRehostInFlight(src);
58
+ usedSrcs.length = 0;
59
+ document.body.innerHTML = "";
60
+ delete document.documentElement.dataset.mediaBase;
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ describe("RehostImages extension", () => {
65
+ it("fires rehost once for a remote image", async () => {
66
+ const rehost = vi.fn();
67
+ const editor = createEditor(rehost);
68
+ const src = track("https://ext.example/a-remote.png");
69
+
70
+ editor.commands.setImage({ src });
71
+ await flush();
72
+
73
+ expect(rehost).toHaveBeenCalledTimes(1);
74
+ expect(rehost).toHaveBeenCalledWith(src);
75
+ });
76
+
77
+ it("fires rehost for a data: URL", async () => {
78
+ const rehost = vi.fn();
79
+ const editor = createEditor(rehost);
80
+ const src = track("data:image/png;base64,AAAAb");
81
+
82
+ editor.commands.setImage({ src });
83
+ await flush();
84
+
85
+ expect(rehost).toHaveBeenCalledWith(src);
86
+ });
87
+
88
+ it("skips same-origin, relative, blob, and media-base images", async () => {
89
+ const rehost = vi.fn();
90
+ document.documentElement.dataset.mediaBase = "https://cdn.mysite.test";
91
+ const editor = createEditor(rehost);
92
+
93
+ editor.commands.setImage({
94
+ src: track(`${window.location.origin}/m/x.png`),
95
+ });
96
+ editor.commands.setImage({ src: track("/relative/y.png") });
97
+ editor.commands.setImage({ src: track("blob:abc-123") });
98
+ editor.commands.setImage({ src: track("https://cdn.mysite.test/z.png") });
99
+ await flush();
100
+
101
+ expect(rehost).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("dedupes identical remote srcs in one document", async () => {
105
+ const rehost = vi.fn();
106
+ const editor = createEditor(rehost);
107
+ const src = track("https://ext.example/dupe.png");
108
+
109
+ editor.commands.setImage({ src });
110
+ editor.commands.setImage({ src });
111
+ await flush();
112
+
113
+ expect(rehost).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("does not re-trigger after the src is swapped to a local URL", async () => {
117
+ const rehost = vi.fn();
118
+ const editor = createEditor(rehost);
119
+ const src = track("https://ext.example/swap.png");
120
+
121
+ editor.commands.setImage({ src });
122
+ await flush();
123
+ expect(rehost).toHaveBeenCalledTimes(1);
124
+
125
+ editor.commands.setContent({
126
+ type: "doc",
127
+ content: [
128
+ { type: "paragraph" },
129
+ {
130
+ type: "image",
131
+ attrs: { src: `${window.location.origin}/m/swap.png` },
132
+ },
133
+ ],
134
+ });
135
+ await flush();
136
+
137
+ expect(rehost).toHaveBeenCalledTimes(1);
138
+ });
139
+ });
@@ -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
  }),