Merge pull request #348 from multica-ai/fix/unify-image-upload-flow

fix(editor): unify image upload flow for paste and button
This commit is contained in:
Naiyuan Qing 2026-04-02 18:05:11 +08:00 committed by GitHub
commit e6a1ff4354
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 88 additions and 109 deletions

View file

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

View file

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

View file

@ -47,7 +47,8 @@ interface RichTextEditorRef {
getMarkdown: () => string; getMarkdown: () => string;
clearContent: () => void; clearContent: () => void;
focus: () => 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({ 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); 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( function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>, onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) { ) {
@ -141,65 +200,13 @@ function createFileUploadExtension(
addProseMirrorPlugins() { addProseMirrorPlugins() {
const { editor } = this; const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => { const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current; const handler = onUploadFileRef.current;
if (!handler) return false; if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
handled = true; await uploadAndInsertFile(editor, file, handler);
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
}
}
} }
return handled; return true;
}; };
return [ return [
@ -386,13 +393,11 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
focus: () => { focus: () => {
editor?.commands.focus(); editor?.commands.focus();
}, },
insertFile: (filename: string, url: string, isImage: boolean) => { uploadFile: (file: File) => {
if (!editor) return; if (!editor || !onUploadFileRef.current) return;
if (isImage) { // Insert at end of doc to avoid replacing selection
editor.chain().focus().setImage({ src: url, alt: filename }).run(); const endPos = editor.state.doc.content.size;
} else { uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
}, },
})); }));

View file

@ -228,14 +228,14 @@ function CommentRow({
defaultValue={entry.content ?? ""} defaultValue={entry.content ?? ""}
placeholder="Edit comment..." placeholder="Edit comment..."
onSubmit={saveEdit} onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100} debounceMs={100}
/> />
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<FileUploadButton <FileUploadButton
size="sm" size="sm"
onUpload={(file) => uploadWithToast(file, { issueId })} onSelect={(file) => editEditorRef.current?.uploadFile(file)}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button> <Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
@ -441,8 +441,7 @@ function CommentCard({
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<FileUploadButton <FileUploadButton
size="sm" size="sm"
onUpload={(file) => uploadWithToast(file, { issueId })} onSelect={(file) => editEditorRef.current?.uploadFile(file)}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button> <Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>

View file

@ -14,15 +14,12 @@ interface CommentInputProps {
function CommentInput({ issueId, onSubmit }: CommentInputProps) { function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null); const editorRef = useRef<RichTextEditorRef>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true); const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload(); const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId }); return await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -30,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return; if (!content || submitting) return;
setSubmitting(true); setSubmitting(true);
try { try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; await onSubmit(content);
await onSubmit(content, ids);
editorRef.current?.clearContent(); editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true); setIsEmpty(true);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -55,11 +50,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
<div className="absolute bottom-1 right-1.5 flex items-center gap-1"> <div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<FileUploadButton <FileUploadButton
size="sm" size="sm"
onUpload={handleUpload} onSelect={(file) => editorRef.current?.uploadFile(file)}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
/> />
<Button <Button
size="icon-xs" size="icon-xs"

View file

@ -667,8 +667,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
)} )}
<FileUploadButton <FileUploadButton
size="sm" size="sm"
onUpload={handleDescriptionUpload} onSelect={(file) => descEditorRef.current?.uploadFile(file)}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/> />
</div> </div>

View file

@ -35,11 +35,10 @@ function ReplyInput({
}: ReplyInputProps) { }: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null); const editorRef = useRef<RichTextEditorRef>(null);
const measureRef = useRef<HTMLDivElement>(null); const measureRef = useRef<HTMLDivElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true); const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload(); const { uploadWithToast } = useFileUpload();
useEffect(() => { useEffect(() => {
const el = measureRef.current; const el = measureRef.current;
@ -53,9 +52,7 @@ function ReplyInput({
}, []); }, []);
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId }); return await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -63,10 +60,8 @@ function ReplyInput({
if (!content || submitting) return; if (!content || submitting) return;
setSubmitting(true); setSubmitting(true);
try { try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; await onSubmit(content);
await onSubmit(content, ids);
editorRef.current?.clearContent(); editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true); setIsEmpty(true);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -105,11 +100,7 @@ function ReplyInput({
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground"> <div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
<FileUploadButton <FileUploadButton
size="sm" size="sm"
onUpload={handleUpload} onSelect={(file) => editorRef.current?.uploadFile(file)}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
/> />
<button <button
type="button" type="button"

View file

@ -419,8 +419,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton <FileUploadButton
onUpload={handleUpload} onSelect={(file) => descEditorRef.current?.uploadFile(file)}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/> />
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}> <Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"} {submitting ? "Creating..." : "Create Issue"}