From ee6c47924d1661ba564a7de33b6addabd080b1f2 Mon Sep 17 00:00:00 2001 From: sunaurus <sander@saarend.com> Date: Sun, 14 Apr 2024 19:18:37 +0300 Subject: [PATCH] feat: implement image uploads in markdown editor Both uploading from clipboard and from the toolbar button are supported --- src/app/(ui)/markdown/MarkdownTextArea.tsx | 112 +++++++++++++++++++-- src/app/(ui)/markdown/imageActions.ts | 13 +++ src/app/comment/Comment.tsx | 2 + src/app/comment/CommentEditor.tsx | 5 +- src/app/post/[id]/CommentsSection.tsx | 1 + 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 src/app/(ui)/markdown/imageActions.ts diff --git a/src/app/(ui)/markdown/MarkdownTextArea.tsx b/src/app/(ui)/markdown/MarkdownTextArea.tsx index 48ef358..38bf9bb 100644 --- a/src/app/(ui)/markdown/MarkdownTextArea.tsx +++ b/src/app/(ui)/markdown/MarkdownTextArea.tsx @@ -3,6 +3,7 @@ import { TextArea, TextAreaProps } from "@/app/(ui)/form/TextArea"; import { ChangeEvent, + ClipboardEvent, KeyboardEvent, ReactNode, useEffect, @@ -10,8 +11,10 @@ import { } from "react"; import classNames from "classnames"; import { CodeBracketIcon, LinkIcon } from "@heroicons/react/16/solid"; -import { Prose } from "@/app/(ui)/markdown/Prose"; import { Markdown } from "@/app/(ui)/markdown/Markdown"; +import { uploadImageAction } from "@/app/(ui)/markdown/imageActions"; +import { Spinner } from "@/app/(ui)/Spinner"; +import { PhotoIcon } from "@heroicons/react/24/outline"; export const MarkdownTextArea = ( props: TextAreaProps & { @@ -26,6 +29,9 @@ export const MarkdownTextArea = ( typeof defaultValue === "string" ? defaultValue : "", ); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState<string | null>(null); + const [previewActive, setPreviewActive] = useState(false); const [selection, setSelection] = useState<{ @@ -76,6 +82,58 @@ export const MarkdownTextArea = ( } }; + const insertImageFromClipboard = async ( + e: ClipboardEvent<HTMLTextAreaElement>, + ) => { + const image = e.clipboardData.files[0]; + if (image) { + await uploadImage(image); + } + }; + + const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => { + const file = e.currentTarget.files?.[0]; + if (file) { + await uploadImage(file); + } + }; + + const uploadImage = async (image: File) => { + setUploading(true); + const formData = new FormData(); + formData.set("image", image); + + const res = await uploadImageAction(formData); + + if (res.url) { + const textarea = document.getElementById( + props.id, + )! as HTMLTextAreaElement; + const selectionStart = textarea.selectionStart; + + const imageMarkdown = ``; + + setValue( + (prev) => + `${prev.substring( + 0, + selectionStart, + )}${imageMarkdown}${prev.substring(selectionStart)}`, + ); + + setSelection({ + start: selectionStart + 2, + end: selectionStart + 10, + }); + } else { + setUploadError(`Error: ${res.msg}` ?? "Error!"); + setTimeout(() => { + setUploadError(null); + }, 2000); + } + setUploading(false); + }; + const makeSelectionLink = () => { surroundSelection("[", "](<enter url>)"); }; @@ -144,7 +202,7 @@ export const MarkdownTextArea = ( }; return ( - <div className={classNames("", className)}> + <div className={classNames("relative", className)}> <div className={`flex items-center justify-end gap-1.5 rounded-t border border-b-0 border-neutral-600 bg-neutral-800 p-0.5 px-4`} @@ -153,6 +211,7 @@ export const MarkdownTextArea = ( className={classNames("text-sm", { "bg-neutral-500 font-bold": !previewActive, })} + disabled={uploading} onClick={() => setPreviewActive(false)} title={"Write"} > @@ -162,6 +221,7 @@ export const MarkdownTextArea = ( className={classNames("mr-auto text-sm", { "bg-neutral-500 font-bold": previewActive, })} + disabled={uploading} onClick={() => setPreviewActive(true)} title={"Preview"} > @@ -169,7 +229,7 @@ export const MarkdownTextArea = ( </ToolbarButton> <ToolbarButton className={"font-bold"} - disabled={previewActive} + disabled={previewActive || uploading} onClick={makeSelectionBold} title={"Bold"} > @@ -177,7 +237,7 @@ export const MarkdownTextArea = ( </ToolbarButton> <ToolbarButton className={"italic"} - disabled={previewActive} + disabled={previewActive || uploading} onClick={makeSelectionItalic} title={"Italic"} > @@ -185,7 +245,7 @@ export const MarkdownTextArea = ( </ToolbarButton> <ToolbarButton className={"font-bold"} - disabled={previewActive} + disabled={previewActive || uploading} onClick={makeSelectionLink} title={"Link"} > @@ -193,12 +253,28 @@ export const MarkdownTextArea = ( </ToolbarButton> <ToolbarButton className={"font-bold"} - disabled={previewActive} + disabled={previewActive || uploading} onClick={makeSelectionCode} title={"Code"} > <CodeBracketIcon className={"h-4"} /> </ToolbarButton> + <label + className={`cursor-pointer rounded bg-neutral-800 p-1 hover:brightness-125 + disabled:cursor-default disabled:hover:brightness-100`} + htmlFor={`${props.id}-upload`} + > + <PhotoIcon className={"h-4"} /> + <input + accept={"image/png, image/jpeg, image/webp"} + className={"hidden"} + disabled={uploading || previewActive} + id={`${props.id}-upload`} + name={"image"} + onChange={handleImageUpload} + type={"file"} + /> + </label> </div> {!previewActive && ( <TextArea @@ -207,21 +283,40 @@ export const MarkdownTextArea = ( "mt-0 rounded-none rounded-b pt-0", textAreaClassName, )} + disabled={uploading} onChange={onChange} onKeyDown={onKeyDown} + onPaste={insertImageFromClipboard} value={value} /> )} {previewActive && ( <div className={classNames( - "rounded-b border border-neutral-600 p-4", + "block rounded-b border border-neutral-600 p-4", props.textAreaClassName, )} > <Markdown content={value} /> </div> )} + {uploading && ( + <div + className={`absolute bottom-0 left-0 flex items-center gap-1 rounded-bl rounded-tr border-b + border-l border-neutral-600 bg-neutral-800 px-4 py-1`} + > + <Spinner /> + {"Uploading..."} + </div> + )} + {uploadError && ( + <div + className={`absolute bottom-0 left-0 flex items-center gap-1 rounded-bl rounded-tr border-b + border-l border-neutral-600 bg-rose-600 px-4 py-1`} + > + {uploadError} + </div> + )} </div> ); }; @@ -236,7 +331,8 @@ const ToolbarButton = (props: { return ( <button className={classNames( - "rounded bg-neutral-800 p-1 hover:brightness-125", + `rounded bg-neutral-800 p-1 hover:brightness-125 disabled:cursor-default + disabled:hover:brightness-100`, props.className, )} disabled={props.disabled} diff --git a/src/app/(ui)/markdown/imageActions.ts b/src/app/(ui)/markdown/imageActions.ts new file mode 100644 index 0000000..0a0f488 --- /dev/null +++ b/src/app/(ui)/markdown/imageActions.ts @@ -0,0 +1,13 @@ +"use server"; + +import { apiClient } from "@/app/apiClient"; + +export const uploadImageAction = async (form: FormData) => { + const image = form.get("image") as File | null; + + if (!image) { + throw new Error("Image missing!"); + } + + return apiClient.uploadImage({ image }); +}; diff --git a/src/app/comment/Comment.tsx b/src/app/comment/Comment.tsx index 0e49773..3a3762a 100644 --- a/src/app/comment/Comment.tsx +++ b/src/app/comment/Comment.tsx @@ -75,6 +75,7 @@ export const Comment = (props: { <CommentEditor className={"w-full"} commentId={props.commentView.comment.id} + id={`edit-${props.commentView.comment.id}`} initialContent={editedContent ?? props.commentView.comment.content} onCancel={() => setIsEditing(false)} onSubmit={(newContent: string) => { @@ -272,6 +273,7 @@ export const Comment = (props: { </div> {isReplying && ( <CommentEditor + id={`reply-${props.commentView.comment.id}`} onCancel={() => setIsReplying(false)} onSubmit={(newComment: CommentView) => { setIsReplying(false); diff --git a/src/app/comment/CommentEditor.tsx b/src/app/comment/CommentEditor.tsx index c9af0f5..b37212b 100644 --- a/src/app/comment/CommentEditor.tsx +++ b/src/app/comment/CommentEditor.tsx @@ -13,6 +13,7 @@ import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea"; type BaseProps = { className?: string; onCancel?(): void; + id: string; }; export type NewCommentProps = BaseProps & { @@ -49,10 +50,10 @@ export const CommentEditor = (props: Props) => { <MarkdownTextArea className={"mb-2 mt-4"} defaultValue={isNewComment(props) ? undefined : props.initialContent} - id={"content"} + id={props.id} name={"content"} required={true} - textAreaClassName={"h-32"} + textAreaClassName={"min-h-32"} /> <div className={"flex justify-end gap-2"}> {props.onCancel && ( diff --git a/src/app/post/[id]/CommentsSection.tsx b/src/app/post/[id]/CommentsSection.tsx index b8011bf..74db6b8 100644 --- a/src/app/post/[id]/CommentsSection.tsx +++ b/src/app/post/[id]/CommentsSection.tsx @@ -36,6 +36,7 @@ export const CommentsSection = (props: { ) : ( <CommentEditor className={"mx-2 max-w-[880px] lg:mx-4"} + id={`new-root-${props.postId}`} postId={props.postId} /> )}