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:
commit
e6a1ff4354
8 changed files with 88 additions and 109 deletions
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue