diff --git a/apps/web/components/common/file-upload-button.tsx b/apps/web/components/common/file-upload-button.tsx index 1c1ddc37..a52bd1dc 100644 --- a/apps/web/components/common/file-upload-button.tsx +++ b/apps/web/components/common/file-upload-button.tsx @@ -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; - 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(null); - const handleChange = async (e: React.ChangeEvent) => { + const handleChange = (e: React.ChangeEvent) => { 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"; diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index faa3bb16..5a0a9ba7 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -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; } diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index c9cf9135..fbdd862c 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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, 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, + 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) | 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( 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); }, })); diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 7b2fd7de..110a7041 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -228,14 +228,14 @@ function CommentRow({ defaultValue={entry.content ?? ""} placeholder="Edit comment..." onSubmit={saveEdit} + onUploadFile={(file) => uploadWithToast(file, { issueId })} debounceMs={100} />
uploadWithToast(file, { issueId })} - onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + onSelect={(file) => editEditorRef.current?.uploadFile(file)} />
@@ -441,8 +441,7 @@ function CommentCard({
uploadWithToast(file, { issueId })} - onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + onSelect={(file) => editEditorRef.current?.uploadFile(file)} />
diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 08bf934d..b3162fac 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -14,15 +14,12 @@ interface CommentInputProps { function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); - const attachmentIdsRef = useRef([]); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); - const { uploadWithToast, uploading } = useFileUpload(); + const { uploadWithToast } = useFileUpload(); const handleUpload = async (file: File) => { - const result = await uploadWithToast(file, { issueId }); - if (result) attachmentIdsRef.current.push(result.id); - return result; + return await uploadWithToast(file, { issueId }); }; const handleSubmit = async () => { @@ -30,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { if (!content || submitting) return; setSubmitting(true); try { - const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; - await onSubmit(content, ids); + await onSubmit(content); editorRef.current?.clearContent(); - attachmentIdsRef.current = []; setIsEmpty(true); } finally { setSubmitting(false); @@ -55,11 +50,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
- editorRef.current?.insertFile(result.filename, result.link, isImage) - } - disabled={uploading} + onSelect={(file) => editorRef.current?.uploadFile(file)} />
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index e3fd5da8..d5125303 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -35,11 +35,10 @@ function ReplyInput({ }: ReplyInputProps) { const editorRef = useRef(null); const measureRef = useRef(null); - const attachmentIdsRef = useRef([]); const [isEmpty, setIsEmpty] = useState(true); const [isExpanded, setIsExpanded] = useState(false); const [submitting, setSubmitting] = useState(false); - const { uploadWithToast, uploading } = useFileUpload(); + const { uploadWithToast } = useFileUpload(); useEffect(() => { const el = measureRef.current; @@ -53,9 +52,7 @@ function ReplyInput({ }, []); const handleUpload = async (file: File) => { - const result = await uploadWithToast(file, { issueId }); - if (result) attachmentIdsRef.current.push(result.id); - return result; + return await uploadWithToast(file, { issueId }); }; const handleSubmit = async () => { @@ -63,10 +60,8 @@ function ReplyInput({ if (!content || submitting) return; setSubmitting(true); try { - const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; - await onSubmit(content, ids); + await onSubmit(content); editorRef.current?.clearContent(); - attachmentIdsRef.current = []; setIsEmpty(true); } finally { setSubmitting(false); @@ -105,11 +100,7 @@ function ReplyInput({
- editorRef.current?.insertFile(result.filename, result.link, isImage) - } - disabled={uploading} + onSelect={(file) => editorRef.current?.uploadFile(file)} />