fix editor image upload caret placement

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-05 10:58:14 +08:00
parent 4779e24816
commit 8ea6d45bcd
2 changed files with 179 additions and 27 deletions

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { ImageExtension } from "./index";
import { uploadAndInsertFile } from "./file-upload";
const BLOB_URL = "blob:test-image";
const FINAL_URL = "https://cdn.example.com/photo.png";
let editors: Editor[] = [];
let originalCreateObjectURL: typeof URL.createObjectURL | undefined;
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined;
function makeEditor() {
const element = document.createElement("div");
document.body.appendChild(element);
const editor = new Editor({
element,
extensions: [
StarterKit,
ImageExtension,
Markdown.configure({ indentation: { style: "space", size: 3 } }),
],
});
editors.push(editor);
return editor;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeUpload(
overrides: Partial<UploadResult> & {
id: string;
link: string;
filename: string;
},
): UploadResult {
return {
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
...overrides,
};
}
beforeEach(() => {
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => BLOB_URL),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
for (const editor of editors) editor.destroy();
editors = [];
document.body.innerHTML = "";
if (originalCreateObjectURL) {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: originalCreateObjectURL,
});
} else {
delete (URL as Partial<typeof URL>).createObjectURL;
}
if (originalRevokeObjectURL) {
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: originalRevokeObjectURL,
});
} else {
delete (URL as Partial<typeof URL>).revokeObjectURL;
}
});
describe("uploadAndInsertFile", () => {
it("lets typing continue in the trailing paragraph after pasted image upload preview", async () => {
const editor = makeEditor();
const upload = deferred<UploadResult | null>();
const handler = vi.fn(() => upload.promise);
const file = new File(["image"], "photo.png", { type: "image/png" });
const uploadTask = uploadAndInsertFile(editor, file, handler);
expect(handler).toHaveBeenCalledWith(file);
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
editor.commands.insertContent("after");
expect(editor.getMarkdown().trimEnd()).toBe(
[`![photo.png](${BLOB_URL})`, "", "after"].join("\n"),
);
upload.resolve(
makeUpload({ id: "attachment-1", link: FINAL_URL, filename: "photo.png" }),
);
await uploadTask;
const saved = editor.getMarkdown().trimEnd();
expect(saved).toBe([`![photo.png](${FINAL_URL})`, "", "after"].join("\n"));
const reparsed = makeEditor();
reparsed.commands.setContent(saved, { contentType: "markdown" });
expect(reparsed.getMarkdown().trimEnd()).toBe(saved);
});
});

View File

@@ -1,5 +1,5 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { createSafeId } from "@multica/core/utils";
@@ -41,21 +41,47 @@ function finalizeFileCard(editor: any, uploadId: string, href: string) {
if (updated) editor.view.dispatch(tr);
}
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
export function findImagePosBySrc(editor: any, src: string): number | null {
if (!editor) return null;
let imagePos: number | null = null;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (imagePos !== null) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
imagePos = pos;
return false;
}
return undefined;
});
if (deleted) editor.view.dispatch(tr);
return imagePos;
}
function removeImageBySrc(editor: any, src: string) {
const imagePos = findImagePosBySrc(editor, src);
if (imagePos === null) return;
const imageNode = editor.state.doc.nodeAt(imagePos);
if (!imageNode) return;
const tr = editor.state.tr.delete(imagePos, imagePos + imageNode.nodeSize);
editor.view.dispatch(tr);
}
function moveSelectionToParagraphAfterImage(editor: any, src: string) {
const imagePos = findImagePosBySrc(editor, src);
if (imagePos === null) return;
const imageNode = editor.state.doc.nodeAt(imagePos);
if (!imageNode) return;
const afterImagePos = imagePos + imageNode.nodeSize;
const $afterImage = editor.state.doc.resolve(afterImagePos);
if ($afterImage.nodeAfter?.type.name !== "paragraph") return;
const paragraphStart = afterImagePos + 1;
const tr = editor.state.tr
.setSelection(TextSelection.create(editor.state.doc, paragraphStart))
.scrollIntoView();
editor.view.dispatch(tr);
}
/**
@@ -78,28 +104,23 @@ export async function uploadAndInsertFile(
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
moveSelectionToParagraphAfterImage(editor, blobUrl);
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
let found = false;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (found) return false;
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
found = true;
return false;
}
return undefined;
});
if (found) editor.view.dispatch(tr);
const imagePos = findImagePosBySrc(editor, blobUrl);
const imageNode = imagePos === null ? null : editor.state.doc.nodeAt(imagePos);
if (imagePos !== null && imageNode) {
const tr = editor.state.tr.setNodeMarkup(imagePos, undefined, {
...imageNode.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
editor.view.dispatch(tr);
}
} else {
removeImageBySrc(editor, blobUrl);
}