Skip to content

Commit

Permalink
feat: implement image uploads in markdown editor
Browse files Browse the repository at this point in the history
Both uploading from clipboard and from the toolbar button are supported
  • Loading branch information
sunaurus committed Apr 14, 2024
1 parent 5ae0737 commit ee6c479
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 10 deletions.
112 changes: 104 additions & 8 deletions src/app/(ui)/markdown/MarkdownTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
import { TextArea, TextAreaProps } from "@/app/(ui)/form/TextArea";
import {
ChangeEvent,
ClipboardEvent,
KeyboardEvent,
ReactNode,
useEffect,
useState,
} 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 & {
Expand All @@ -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<{
Expand Down Expand Up @@ -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 = `![alt text](${res.url})`;

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>)");
};
Expand Down Expand Up @@ -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`}
Expand All @@ -153,6 +211,7 @@ export const MarkdownTextArea = (
className={classNames("text-sm", {
"bg-neutral-500 font-bold": !previewActive,
})}
disabled={uploading}
onClick={() => setPreviewActive(false)}
title={"Write"}
>
Expand All @@ -162,43 +221,60 @@ export const MarkdownTextArea = (
className={classNames("mr-auto text-sm", {
"bg-neutral-500 font-bold": previewActive,
})}
disabled={uploading}
onClick={() => setPreviewActive(true)}
title={"Preview"}
>
{"Preview"}
</ToolbarButton>
<ToolbarButton
className={"font-bold"}
disabled={previewActive}
disabled={previewActive || uploading}
onClick={makeSelectionBold}
title={"Bold"}
>
{"B"}
</ToolbarButton>
<ToolbarButton
className={"italic"}
disabled={previewActive}
disabled={previewActive || uploading}
onClick={makeSelectionItalic}
title={"Italic"}
>
{"i"}
</ToolbarButton>
<ToolbarButton
className={"font-bold"}
disabled={previewActive}
disabled={previewActive || uploading}
onClick={makeSelectionLink}
title={"Link"}
>
<LinkIcon className={"h-4"} />
</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
Expand All @@ -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>
);
};
Expand All @@ -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}
Expand Down
13 changes: 13 additions & 0 deletions src/app/(ui)/markdown/imageActions.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
2 changes: 2 additions & 0 deletions src/app/comment/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/app/comment/CommentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";
type BaseProps = {
className?: string;
onCancel?(): void;
id: string;
};

export type NewCommentProps = BaseProps & {
Expand Down Expand Up @@ -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 && (
Expand Down
1 change: 1 addition & 0 deletions src/app/post/[id]/CommentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
Expand Down

0 comments on commit ee6c479

Please sign in to comment.