@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.
- package/bin/commands/import-site.js +45 -20
- package/dist/app-B5P1b4TF.js +6 -0
- package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BUxR-E8O.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
- package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
- package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +75 -0
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +8 -2
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-nav-manager.ts +142 -2
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -0
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -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/rehost-images.ts +108 -0
- package/src/i18n/__tests__/fallback.test.ts +24 -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 +54 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +64 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +56 -2
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- 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 +63 -3
- package/src/lib/post-display.ts +14 -8
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +41 -2
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/__tests__/navigation.test.ts +190 -1
- package/src/services/__tests__/post-timeline.test.ts +119 -2
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/layouts/_default/rss.xml +2 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/index.ts +27 -17
- package/src/services/media.ts +111 -0
- package/src/services/navigation.ts +155 -1
- package/src/services/post.ts +24 -7
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +26 -8
- package/src/styles/ui.css +149 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- 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,
|
|
30
|
+
function replaceInlineImage(editor: Editor, fromUrl: string, realUrl: string) {
|
|
30
31
|
if (editor.isDestroyed) return;
|
|
31
32
|
const { doc } = editor.state;
|
|
32
|
-
|
|
33
|
+
// Replace every node sharing this src. Blob placeholders are unique, but a
|
|
34
|
+
// deduped remote URL can appear in several nodes after a paste, and they all
|
|
35
|
+
// need to point at the one rehosted copy. setNodeMarkup preserves node size,
|
|
36
|
+
// so positions stay valid across the walk.
|
|
33
37
|
doc.descendants((node, pos) => {
|
|
34
|
-
if (
|
|
38
|
+
if (node.type.name !== "image" || node.attrs.src !== fromUrl) {
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
37
41
|
|
|
@@ -45,7 +49,6 @@ function replaceInlineImage(editor: Editor, blobUrl: string, realUrl: string) {
|
|
|
45
49
|
return true;
|
|
46
50
|
})
|
|
47
51
|
.run();
|
|
48
|
-
replaced = true;
|
|
49
52
|
});
|
|
50
53
|
}
|
|
51
54
|
|
|
@@ -65,6 +68,26 @@ function removeInlineImage(editor: Editor, blobUrl: string) {
|
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Apply the outcome of an inline upload/rehost to the placeholder node(s).
|
|
73
|
+
*
|
|
74
|
+
* On success the src is swapped to the final URL. On failure a `blob:`
|
|
75
|
+
* placeholder (from the insert flow) is removed, while a remote/`data:`
|
|
76
|
+
* placeholder (from the rehost flow) is left untouched so the original image
|
|
77
|
+
* still shows — rehosting is best-effort.
|
|
78
|
+
*/
|
|
79
|
+
function settlePlaceholder(
|
|
80
|
+
editor: Editor,
|
|
81
|
+
placeholderSrc: string,
|
|
82
|
+
realUrl: string | null,
|
|
83
|
+
) {
|
|
84
|
+
if (realUrl) {
|
|
85
|
+
replaceInlineImage(editor, placeholderSrc, realUrl);
|
|
86
|
+
} else if (placeholderSrc.startsWith("blob:")) {
|
|
87
|
+
removeInlineImage(editor, placeholderSrc);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
/**
|
|
69
92
|
* Uploads an image file and inserts it into the editor as an inline image.
|
|
70
93
|
*
|
|
@@ -93,9 +116,9 @@ export async function uploadAndInsertInlineImage(
|
|
|
93
116
|
|
|
94
117
|
try {
|
|
95
118
|
const realUrl = await uploaded;
|
|
96
|
-
|
|
119
|
+
settlePlaceholder(editor, placeholderUrl, realUrl);
|
|
97
120
|
} catch {
|
|
98
|
-
|
|
121
|
+
settlePlaceholder(editor, placeholderUrl, null);
|
|
99
122
|
} finally {
|
|
100
123
|
// Only cleanup if not adopted by another editor
|
|
101
124
|
if (inflightUploads.delete(placeholderUrl)) {
|
|
@@ -105,16 +128,88 @@ export async function uploadAndInsertInlineImage(
|
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
/**
|
|
108
|
-
*
|
|
131
|
+
* Result of the remote-image sideload endpoint.
|
|
132
|
+
*/
|
|
133
|
+
export interface SideloadResult {
|
|
134
|
+
id: string;
|
|
135
|
+
url: string;
|
|
136
|
+
width?: number;
|
|
137
|
+
height?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ask the server to rehost a remote image URL into the site's own storage.
|
|
142
|
+
*
|
|
143
|
+
* The server fetches the bytes (browser fetch of a third-party image is blocked
|
|
144
|
+
* by CORS), stores them, and returns the new public URL.
|
|
145
|
+
*
|
|
146
|
+
* @param url - The remote http(s) image URL
|
|
147
|
+
* @param alt - Optional alt text to persist on the media row
|
|
148
|
+
* @returns The created media's id, public URL, and dimensions
|
|
149
|
+
* @throws {Error} When the endpoint responds with a non-OK status
|
|
150
|
+
*/
|
|
151
|
+
export async function sideloadImage(
|
|
152
|
+
url: string,
|
|
153
|
+
alt?: string,
|
|
154
|
+
): Promise<SideloadResult> {
|
|
155
|
+
const res = await fetch(publicPath("/api/uploads/sideload"), {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ url, alt }),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
throw new Error(`Sideload failed: HTTP ${res.status}`);
|
|
162
|
+
}
|
|
163
|
+
return (await res.json()) as SideloadResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Rehost an image node that already exists in the document (inserted by a paste)
|
|
168
|
+
* whose `src` is a remote or `data:` URL. Tracks the work in the shared registry
|
|
169
|
+
* so submit waits for it, swaps the src to the stored URL on success, and leaves
|
|
170
|
+
* the node untouched on failure (the original image keeps showing).
|
|
171
|
+
*
|
|
172
|
+
* Does NOT insert a node — the paste already created it.
|
|
173
|
+
*
|
|
174
|
+
* @param editor - TipTap editor containing the placeholder node
|
|
175
|
+
* @param placeholderSrc - The current (remote/data) src of the node to rehost
|
|
176
|
+
* @param resolveUrl - Produces the final stored URL (server sideload or client upload)
|
|
177
|
+
* @returns Resolves after the node is updated or left in place
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* await rehostInlineImage(editor, src, () => sideloadImage(src).then((r) => r.url));
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export async function rehostInlineImage(
|
|
184
|
+
editor: Editor,
|
|
185
|
+
placeholderSrc: string,
|
|
186
|
+
resolveUrl: () => Promise<string>,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const uploaded = resolveUrl();
|
|
189
|
+
inflightUploads.set(placeholderSrc, uploaded);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const realUrl = await uploaded;
|
|
193
|
+
settlePlaceholder(editor, placeholderSrc, realUrl);
|
|
194
|
+
} catch {
|
|
195
|
+
settlePlaceholder(editor, placeholderSrc, null);
|
|
196
|
+
} finally {
|
|
197
|
+
inflightUploads.delete(placeholderSrc);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Adopt in-flight inline image uploads/rehosts into a new editor instance.
|
|
109
203
|
*
|
|
110
|
-
* Scans the editor's document for
|
|
111
|
-
*
|
|
112
|
-
*
|
|
204
|
+
* Scans the editor's document for image srcs that match pending registry entries
|
|
205
|
+
* (`blob:` placeholders from the insert flow, or remote/`data:` placeholders
|
|
206
|
+
* from the paste-rehost flow). For each match, takes ownership from the original
|
|
207
|
+
* editor and sets up replacement/removal watchers on the new editor.
|
|
113
208
|
*
|
|
114
|
-
* Call this immediately after `setContent` with JSON that may contain
|
|
115
|
-
* (e.g. after fullscreen close transfers content back to compose
|
|
209
|
+
* Call this immediately after `setContent` with JSON that may contain pending
|
|
210
|
+
* placeholders (e.g. after fullscreen close transfers content back to compose).
|
|
116
211
|
*
|
|
117
|
-
* @param editor - The TipTap editor that now contains the content
|
|
212
|
+
* @param editor - The TipTap editor that now contains the placeholder content
|
|
118
213
|
* @returns Array of promises that resolve when each adopted upload completes
|
|
119
214
|
*/
|
|
120
215
|
export function adoptPendingInlineImageUploads(
|
|
@@ -126,7 +221,7 @@ export function adoptPendingInlineImageUploads(
|
|
|
126
221
|
doc.descendants((node) => {
|
|
127
222
|
if (node.type.name !== "image") return;
|
|
128
223
|
const src = node.attrs.src as string;
|
|
129
|
-
if (
|
|
224
|
+
if (typeof src !== "string") return;
|
|
130
225
|
|
|
131
226
|
const uploaded = inflightUploads.get(src);
|
|
132
227
|
if (!uploaded) return;
|
|
@@ -137,14 +232,17 @@ export function adoptPendingInlineImageUploads(
|
|
|
137
232
|
inflightUploads.delete(src);
|
|
138
233
|
adoptedUploads.set(src, uploaded);
|
|
139
234
|
|
|
235
|
+
// Blob placeholders own an object URL and are removed on failure; remote/
|
|
236
|
+
// data rehost placeholders have neither (keep the node, nothing to revoke).
|
|
237
|
+
const isBlob = src.startsWith("blob:");
|
|
140
238
|
const promise = uploaded
|
|
141
239
|
.then(
|
|
142
240
|
(realUrl) => replaceInlineImage(editor, src, realUrl),
|
|
143
|
-
() =>
|
|
241
|
+
() => settlePlaceholder(editor, src, null),
|
|
144
242
|
)
|
|
145
243
|
.finally(() => {
|
|
146
244
|
adoptedUploads.delete(src);
|
|
147
|
-
URL.revokeObjectURL(src);
|
|
245
|
+
if (isBlob) URL.revokeObjectURL(src);
|
|
148
246
|
});
|
|
149
247
|
adopted.push(promise);
|
|
150
248
|
});
|
|
@@ -153,80 +251,106 @@ export function adoptPendingInlineImageUploads(
|
|
|
153
251
|
}
|
|
154
252
|
|
|
155
253
|
/**
|
|
156
|
-
* Resolve all
|
|
254
|
+
* Resolve all pending inline image placeholders in a TipTap JSON document.
|
|
157
255
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
256
|
+
* Covers both placeholder kinds tracked in the registries: `blob:` URLs from
|
|
257
|
+
* the insert/upload flow and remote/`data:` URLs from the paste-rehost flow.
|
|
258
|
+
* Waits for the pending work and returns a new JSON tree with stored URLs.
|
|
259
|
+
* Unresolved `blob:` placeholders (upload failed) are removed; unresolved
|
|
260
|
+
* remote/`data:` placeholders are kept with their original src (rehost is
|
|
261
|
+
* best-effort, so the original image still shows).
|
|
160
262
|
*
|
|
161
263
|
* Used by the submit bridge to finalize content before posting.
|
|
162
264
|
*
|
|
163
|
-
* @param json - TipTap JSON document that may contain
|
|
164
|
-
* @returns Resolved JSON with
|
|
265
|
+
* @param json - TipTap JSON document that may contain placeholder image srcs
|
|
266
|
+
* @returns Resolved JSON with placeholder srcs replaced (or kept/removed)
|
|
165
267
|
*/
|
|
166
268
|
export async function resolveInlineImageUrls(
|
|
167
269
|
json: JSONContent | null,
|
|
168
270
|
): Promise<JSONContent | null> {
|
|
169
271
|
if (!json) return json;
|
|
170
272
|
|
|
171
|
-
// Collect
|
|
172
|
-
const
|
|
173
|
-
|
|
273
|
+
// Collect every placeholder src present in the registries + its promise.
|
|
274
|
+
const placeholders = new Map<string, Promise<string>>();
|
|
275
|
+
collectPlaceholderUrls(json, placeholders);
|
|
174
276
|
|
|
175
|
-
if (
|
|
277
|
+
if (placeholders.size === 0) return json;
|
|
176
278
|
|
|
177
|
-
// Wait for all
|
|
178
|
-
const
|
|
279
|
+
// Wait for all pending work; record success (URL) or failure (null) per src.
|
|
280
|
+
const outcomes = new Map<string, string | null>();
|
|
179
281
|
await Promise.allSettled(
|
|
180
|
-
Array.from(
|
|
181
|
-
|
|
182
|
-
|
|
282
|
+
Array.from(placeholders.entries()).map(async ([src, promise]) => {
|
|
283
|
+
try {
|
|
284
|
+
outcomes.set(src, await promise);
|
|
285
|
+
} catch {
|
|
286
|
+
outcomes.set(src, null);
|
|
287
|
+
}
|
|
183
288
|
}),
|
|
184
289
|
);
|
|
185
290
|
|
|
186
|
-
return
|
|
291
|
+
return applyPlaceholderOutcomes(json, outcomes);
|
|
187
292
|
}
|
|
188
293
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Whether a TipTap JSON document still references inline image placeholders that
|
|
296
|
+
* are pending upload/rehost. Used to decide the "uploading" toast and whether to
|
|
297
|
+
* run {@link resolveInlineImageUrls} before submit.
|
|
298
|
+
*
|
|
299
|
+
* @param json - TipTap JSON document to inspect
|
|
300
|
+
* @returns True if any image src is a pending placeholder
|
|
301
|
+
*/
|
|
302
|
+
export function hasPendingInlineImagePlaceholders(
|
|
303
|
+
json: JSONContent | null,
|
|
304
|
+
): boolean {
|
|
305
|
+
if (!json) return false;
|
|
306
|
+
const placeholders = new Map<string, Promise<string>>();
|
|
307
|
+
collectPlaceholderUrls(json, placeholders);
|
|
308
|
+
return placeholders.size > 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function collectPlaceholderUrls(
|
|
312
|
+
node: JSONContent,
|
|
313
|
+
out: Map<string, Promise<string>>,
|
|
314
|
+
) {
|
|
315
|
+
if (node.type === "image" && typeof node.attrs?.src === "string") {
|
|
316
|
+
const src = node.attrs.src;
|
|
317
|
+
const promise = inflightUploads.get(src) ?? adoptedUploads.get(src);
|
|
197
318
|
if (promise) {
|
|
198
|
-
out.set(
|
|
319
|
+
out.set(src, promise);
|
|
199
320
|
}
|
|
200
321
|
}
|
|
201
322
|
if (node.content) {
|
|
202
323
|
for (const child of node.content) {
|
|
203
|
-
|
|
324
|
+
collectPlaceholderUrls(child, out);
|
|
204
325
|
}
|
|
205
326
|
}
|
|
206
327
|
}
|
|
207
328
|
|
|
208
|
-
function
|
|
329
|
+
function applyPlaceholderOutcomes(
|
|
209
330
|
node: JSONContent,
|
|
210
|
-
|
|
331
|
+
outcomes: Map<string, string | null>,
|
|
211
332
|
): JSONContent {
|
|
212
|
-
// Remove image nodes with unresolved blob URLs (upload failed or orphaned)
|
|
213
333
|
if (
|
|
214
334
|
node.type === "image" &&
|
|
215
335
|
typeof node.attrs?.src === "string" &&
|
|
216
|
-
node.attrs.src
|
|
336
|
+
outcomes.has(node.attrs.src)
|
|
217
337
|
) {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
338
|
+
const src = node.attrs.src;
|
|
339
|
+
const realUrl = outcomes.get(src);
|
|
340
|
+
if (realUrl) {
|
|
341
|
+
return { ...node, attrs: { ...node.attrs, src: realUrl } };
|
|
342
|
+
}
|
|
343
|
+
// Unresolved: drop blob placeholders (orphaned upload), keep remote/data.
|
|
344
|
+
if (src.startsWith("blob:")) {
|
|
221
345
|
return { type: "__removed__" };
|
|
222
346
|
}
|
|
223
|
-
return
|
|
347
|
+
return node;
|
|
224
348
|
}
|
|
225
349
|
|
|
226
350
|
if (!node.content) return node;
|
|
227
351
|
|
|
228
352
|
const newContent = node.content
|
|
229
|
-
.map((child) =>
|
|
353
|
+
.map((child) => applyPlaceholderOutcomes(child, outcomes))
|
|
230
354
|
.filter((child) => child.type !== "__removed__");
|
|
231
355
|
|
|
232
356
|
return newContent === node.content ? node : { ...node, content: newContent };
|