fix(editor): unify image upload flow for paste and button

Paste/drop and attachment button previously used separate upload paths.
The button uploaded first then called insertFile (which replaced the
current selection), while paste inserted a blob preview first. This
caused the second image to overwrite the first when both were used.

Now both paths share the same flow via uploadAndInsertFile():
blob preview with uploading animation → background upload → replace URL.

- Extract shared uploadAndInsertFile() function
- Replace insertFile ref method with uploadFile (inserts at doc end)
- Simplify FileUploadButton to onSelect(file) — no more onUpload/onInsert
- Wire onUploadFile in comment edit mode (was missing, upload was no-op)
- Unify image border-radius CSS for both editing and readonly modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-02 18:02:28 +08:00
parent 565afed447
commit 7cc4e63e0e
8 changed files with 88 additions and 109 deletions

View file

@ -3,33 +3,28 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
interface FileUploadButtonProps {
onUpload: (file: File) => Promise<UploadResult | null>;
onInsert?: (result: UploadResult, isImage: boolean) => void;
/** Called with the selected File — caller handles upload. */
onSelect: (file: File) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onUpload,
onInsert,
onSelect,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await onUpload(file);
if (result && onInsert) {
onInsert(result, file.type.startsWith("image/"));
}
onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";

View file

@ -235,8 +235,8 @@
display: inline;
}
/* Images in readonly mode */
.rich-text-editor.readonly img {
/* Images — shared styling for both editing and readonly */
.rich-text-editor img {
border-radius: var(--radius);
margin: 0.5rem 0;
}

View file

@ -47,7 +47,8 @@ interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
insertFile: (filename: string, url: string, isImage: boolean) => void;
/** Upload a file and insert it into the editor (blob preview → upload → replace). */
uploadFile: (file: File) => void;
}
const LinkExtension = Link.extend({ inclusive: false }).configure({
@ -133,6 +134,64 @@ function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
if (deleted) editor.view.dispatch(tr);
}
/**
* Shared upload flow: insert blob preview upload replace with real URL.
* Used by both paste/drop (at cursor) and button upload (at end of doc).
*/
async function uploadAndInsertFile(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any,
file: File,
handler: (file: File) => Promise<UploadResult | null>,
pos?: number,
) {
const isImage = file.type.startsWith("image/");
if (isImage) {
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
}
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
@ -141,65 +200,13 @@ function createFileUploadExtension(
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => {
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) {
handled = true;
const isImage = file.type.startsWith("image/");
if (isImage) {
// Instant preview via blob URL with uploading flag for CSS styling
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
try {
const result = await handler(file);
if (!result) continue;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
await uploadAndInsertFile(editor, file, handler);
}
return handled;
return true;
};
return [
@ -386,13 +393,11 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
focus: () => {
editor?.commands.focus();
},
insertFile: (filename: string, url: string, isImage: boolean) => {
if (!editor) return;
if (isImage) {
editor.chain().focus().setImage({ src: url, alt: filename }).run();
} else {
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
uploadFile: (file: File) => {
if (!editor || !onUploadFileRef.current) return;
// Insert at end of doc to avoid replacing selection
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
}));