From 8ea6d45bcd428562c2b67ca2402b192e873c4966 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:58:14 +0800 Subject: [PATCH] fix editor image upload caret placement Co-authored-by: multica-agent --- .../editor/extensions/file-upload.test.ts | 131 ++++++++++++++++++ .../views/editor/extensions/file-upload.ts | 75 ++++++---- 2 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 packages/views/editor/extensions/file-upload.test.ts diff --git a/packages/views/editor/extensions/file-upload.test.ts b/packages/views/editor/extensions/file-upload.test.ts new file mode 100644 index 0000000000..36bf86a4f6 --- /dev/null +++ b/packages/views/editor/extensions/file-upload.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeUpload( + overrides: Partial & { + 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).createObjectURL; + } + + if (originalRevokeObjectURL) { + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectURL, + }); + } else { + delete (URL as Partial).revokeObjectURL; + } +}); + +describe("uploadAndInsertFile", () => { + it("lets typing continue in the trailing paragraph after pasted image upload preview", async () => { + const editor = makeEditor(); + const upload = deferred(); + 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); + }); +}); diff --git a/packages/views/editor/extensions/file-upload.ts b/packages/views/editor/extensions/file-upload.ts index f46c8ab0f2..898bef8e01 100644 --- a/packages/views/editor/extensions/file-upload.ts +++ b/packages/views/editor/extensions/file-upload.ts @@ -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); }