mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 14:19:13 +02:00
fix editor image upload caret placement
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
131
packages/views/editor/extensions/file-upload.test.ts
Normal file
131
packages/views/editor/extensions/file-upload.test.ts
Normal 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(
|
||||
[``, "", "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([``, "", "after"].join("\n"));
|
||||
|
||||
const reparsed = makeEditor();
|
||||
reparsed.commands.setContent(saved, { contentType: "markdown" });
|
||||
expect(reparsed.getMarkdown().trimEnd()).toBe(saved);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user