😄

ファイルをコピペ・ドラッグアンドドロップするとストレージにアップロードされたファイルのURLが自動で挿入されるtextareaの実装してみた

に公開

ファイルをコピペ・ドラッグアンドドロップするとストレージにアップロードされたファイルのURLが自動で挿入されるtextareaの実装してみた

ZennやQiitaではファイルをドラッグアンドドロップすると自動でファイルがアップロードされてURLがフォームに自動で挿入されます。
このフォームについて気になったので動きだけ同じものを作ってみました。

実際の画面

実際の画面

コード

使用技術

  • Next.js
  • Vercel Blob
  • zod
  • react-hook-form

実装

関連するところだけ抜き出して書きます。
フロント部分でファイルをアップロードし、返ってきたURLをvalueに追加するだけで動きは似たようなものができました。
ネットワークタブを見てもzennやQiitaのストレージとの通信しているっぽいのがあるのでまあ良しとしてます。

ファイルのアップロード(フロント)

type PasteWrapperProps = React.ComponentProps<"div"> & {
  setValue: UseFormSetValue<{
    text: string;
  }>;
  getValues: UseFormGetValues<{
    text: string;
  }>;
};

const PasteWrapper = ({
  children,
  setValue,
  getValues,
  className,
  ...props
}: PasteWrapperProps) => {
  const [isDragActive, setDragActive] = useState<boolean>(false);

  const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
    if (
      e.dataTransfer &&
      e.dataTransfer.items &&
      e.dataTransfer.items.length > 0
    ) {
      e.preventDefault();
      e.stopPropagation();
      setDragActive(true);
    }
  };

  const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragActive(false);
  };

  const onDragOver = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setDragActive(true);
  };

  const onDrop = async (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();

    const prevValue = getValues("text");

    const files = e.dataTransfer.files;

    if (!files) return;

    const parsedResult = fileSchema.safeParse({
      file: files[0],
    });

    if (parsedResult.success) {
      const file = parsedResult.data.file;

      if (!file) return;

      const { url } = await uploadFile(file);

      const fileUrlText = `![${file.name}](${url})`;
      const textAreaValue = prevValue.length
        ? prevValue + "\n" + fileUrlText
        : fileUrlText;

      setValue("text", textAreaValue, {
        shouldValidate: true,
      });
      setDragActive(false);
    } else {
      toast.error("エラーです");
      setDragActive(false);
      return;
    }
  };

  const onPaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
    e.preventDefault();

    const prevValue = getValues("text");

    const { items } = e.clipboardData;

    if (items[0].kind === "string") {
      items[0].getAsString((value) => {
        setValue("text", prevValue + value, {
          shouldValidate: true,
        });
      });
    }

    if (items[0].kind === "file") {
      const file = items[0].getAsFile();

      const parsedResult = fileSchema.safeParse({
        file: file,
      });

      if (parsedResult.success) {
        const file = parsedResult.data.file;

        if (!file) return;

        const { url } = await uploadFile(file);

        const fileUrlText = `![${file.name}](${url})`;
        const textAreaValue = prevValue.length
          ? prevValue + "\n" + fileUrlText
          : fileUrlText;

        setValue("text", textAreaValue, {
          shouldValidate: true,
        });
        setDragActive(false);
      } else {
        toast.error("エラーです");
        setDragActive(false);
        return;
      }
    }
  };

  return (
    <div
      {...props}
      onDragEnter={onDragEnter}
      onDragLeave={onDragLeave}
      onDragOver={onDragOver}
      onDrop={onDrop}
      onPaste={onPaste}
      className={cn(className, isDragActive && "bg-slate-100")}
    >
      {children}
    </div>
  );
};

onPasteではペーストした値が文字列かファイルかで判断して文字列ならvalueに文字列を追加、ファイルならAPI側にファイルデータを送信します。

onDragも同様にファイルをバリデーションしたのち、問題なければAPI側にファイルデータを送信しています。

ファイルのアップロードはvercelの記事を参考にしました。

ファイルのアップロード(API)

import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { NextResponse } from "next/server";

export async function POST(request: Request): Promise<NextResponse> {
  const body = (await request.json()) as HandleUploadBody;

  try {
    const jsonResponse = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async () =>
        /* clientPayload */
        {
          // Generate a client token for the browser to upload the file
          // ⚠️ Authenticate and authorize users before generating the token.
          // Otherwise, you're allowing anonymous uploads.

          return {
            allowedContentTypes: [
              "image/jpeg",
              "image/png",
              "image/gif",
              "image/svg+xml",
            ],
            addRandomSuffix: true,
            tokenPayload: JSON.stringify({
              // optional, sent to your server on upload completion
              // you could pass a user id from auth, or a value from clientPayload
            }),
          };
        },
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        // Get notified of client upload completion
        // ⚠️ This will not work on `localhost` websites,
        // Use ngrok or similar to get the full upload flow

        console.log("blob upload completed", blob, tokenPayload);

        // try {
        //   Run any logic after the file upload completed
        //   const { userId } = JSON.parse(tokenPayload);
        //   await db.update({ avatar: blob.url, userId });
        // } catch (error) {
        //   throw new Error("Could not update user");
        // }
      },
    });

    return NextResponse.json(jsonResponse);
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 400 }, // The webhook will retry 5 times waiting for a 200
    );
  }
}

これも記事のまんまです。

テキストエリア

react-hook-formのuseControllerを使用して入力を制御しています。特に特殊なことは何もしていません。

type CustomTextareaProps = React.ComponentProps<typeof Textarea>;

const CustomTextarea = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
  control,
  name,
  ...props
}: CustomTextareaProps & UseControllerProps<TFieldValues, TName>) => {
  const [contentHeight, setContentHeight] = useState<number>(700);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const { field } = useController({
    name: name,
    control: control,
  });

  const onChangeContent = (e: ChangeEvent<HTMLTextAreaElement>) => {
    const value = e.target.value;
    if (textareaRef.current) {
      setContentHeight(textareaRef.current.scrollHeight);
    }

    if (!value.length) {
      setContentHeight(40);
    }

    field.onChange(e);
  };

  return (
    <Textarea
      {...field}
      {...props}
      className={cn(
        "text-sm shadow-none border-none focus-visible:ring-transparent focus:border-transparent resize-none overflow-hidden",
      )}
      onChange={onChangeContent}
      ref={textareaRef}
      style={{
        height: contentHeight,
      }}
      placeholder="入力してください…"
    />
  );
};

自分の実装だと無限にファイルをストレージに送り付けることが可能だと思うので、その対策をどうやっているのかどう実装しているのか気になりました。Upstashなどでレート制限が効果的なのでしょうか。

参考

最後に

間違っていることがあれば、コメントに書いていただけると幸いです。
よろしくお願いいたします。

GitHubで編集を提案

Discussion