@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
|
@@ -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
|
|
161
|
-
this.featuredFeedUrl = "/feed
|
|
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(
|
|
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 {
|
|
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
|
|
683
|
-
? detail.threadPosts.some((p) => p.body
|
|
684
|
-
: detail.body
|
|
685
|
-
const hasPending = detail.pendingAttachments.length > 0 ||
|
|
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
|
|
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
|
|
944
|
-
|
|
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
|
}),
|