note에 적다보니 적을 게 없어지는 게 문제

낚시하는 곰·2025년 6월 27일
1

jungle TIL

목록 보기
19/20

ToolBar

진짜 만들기 싫었지만 기존 코드에서는 img 삽입 방식을 커스텀하기가 어려워서 한땀한땀 다 가져다 붙였다.

const ToolBar = ({ editor, setLink, addImage }: { editor: Editor; setLink: () => void; addImage: () => void }) => {
  return (
    <div className="toolbar">
      <div className="itemBox">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={`toolbarBtn ${editor.isActive("bold") ? "is-active" : ""}`}
        >
          <BoldIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={`toolbarBtn ${editor.isActive("italic") ? "is-active" : ""}`}
        >
          <ItalicIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={`toolbarBtn ${editor.isActive("strike") ? "is-active" : ""}`}
        >
          <StrikeIcon className="w-4 h-4" />
        </button>

        <button onClick={setLink} className={`toolbarBtn ${editor.isActive("link") ? "is-active" : ""}`}>
          <LinkIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={`toolbarBtn ${editor.isActive("orderedList") ? "is-active" : ""}`}
        >
          <ListOrderedIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={`toolbarBtn ${editor.isActive("bulletList") ? "is-active" : ""}`}
        >
          <ListIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleBlockquote().run()}
          className={`toolbarBtn ${editor.isActive("blockquote") ? "is-active" : ""}`}
        >
          <BlockQuoteIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleCode().run()}
          className={`toolbarBtn ${editor.isActive("code") ? "is-active" : ""}`}
        >
          <CodeIcon className="w-4 h-4" />
        </button>

        <button
          onClick={() => editor.chain().focus().toggleCodeBlock().run()}
          className={`toolbarBtn ${editor.isActive("codeBlock") ? "is-active" : ""}`}
        >
          <CodeBlockIcon className="w-4 h-4" />
        </button>

        <button onClick={addImage} className={`toolbarBtn ${editor.isActive("image") ? "is-active" : ""}`}>
          <HardDriveUploadIcon className="w-4 h-4" />
        </button>
      </div>
    </div>
  );
};

ToolTip

img와 link를 넣다보니 코드가 굉장히 길어졌다.
이 부분의 핵심은 file 삽입과 삽입된 file의 미리보기 기능이다.

export default () => {
  const [text, setText] = useState("helloWorld");
  const fileInputRef = useRef<HTMLInputElement>(null);
  const editor = useEditor({
    editable: true,
    extensions: [
      Document,
      Paragraph,
      Text,
      Blockquote,
      Bold,
      Italic,
      Strike,
      Code,
      ListItem,
      OrderedList,
      BulletList,
      CodeBlock,
      Dropcursor,
      Image,
      Link.configure({
        openOnClick: false,
        autolink: true,
        defaultProtocol: "https",
        protocols: ["http", "https"],
        isAllowedUri: (url, ctx) => {
          try {
            // construct URL
            const parsedUrl = url.includes(":") ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`);

            // use default validation
            if (!ctx.defaultValidate(parsedUrl.href)) {
              return false;
            }

            // disallowed protocols
            const disallowedProtocols = ["ftp", "file", "mailto"];
            const protocol = parsedUrl.protocol.replace(":", "");

            if (disallowedProtocols.includes(protocol)) {
              return false;
            }

            // only allow protocols specified in ctx.protocols
            const allowedProtocols = ctx.protocols.map((p) => (typeof p === "string" ? p : p.scheme));

            if (!allowedProtocols.includes(protocol)) {
              return false;
            }

            // disallowed domains
            const disallowedDomains = ["example-phishing.com", "malicious-site.net"];
            const domain = parsedUrl.hostname;

            if (disallowedDomains.includes(domain)) {
              return false;
            }

            // all checks have passed
            return true;
          } catch {
            return false;
          }
        },
        shouldAutoLink: (url) => {
          try {
            // construct URL
            const parsedUrl = url.includes(":") ? new URL(url) : new URL(`https://${url}`);

            // only auto-link if the domain is not in the disallowed list
            const disallowedDomains = ["example-no-autolink.com", "another-no-autolink.com"];
            const domain = parsedUrl.hostname;

            return !disallowedDomains.includes(domain);
          } catch {
            return false;
          }
        },
      }),
    ],
    content: text,
  });

  const setLink = useCallback(() => {
    const previousUrl = editor.getAttributes("link").href;
    const url = window.prompt("URL", previousUrl);

    // cancelled
    if (url === null) {
      return;
    }

    // empty
    if (url === "") {
      editor.chain().focus().extendMarkRange("link").unsetLink().run();

      return;
    }

    // update link
    try {
      editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
    } catch (e) {
      alert(e.message);
    }
  }, [editor]);

  const convertFileToBase64 = (file: File): Promise<string> => {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result as string);
      reader.onerror = reject;
      reader.readAsDataURL(file);
    });
  };

  const handleFileSelect = useCallback(
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const file = event.target.files?.[0];
      if (file && editor) {
        // 파일 유효성 검사
        // if (!file.type.startsWith("image/")) {
        //   alert("이미지 파일만 선택할 수 있습니다.");
        //   return;
        // }

        // 파일 크기 제한 (5MB)
        if (file.size > 5 * 1024 * 1024) {
          alert("파일 크기는 5MB 이하여야 합니다.");
          return;
        }

        // 파일을 base64로 변환
        const base64 = await convertFileToBase64(file);

        // 에디터에 이미지 삽입
        if (file.type.startsWith("image/")) {
          editor.chain().focus().setImage({ src: base64 }).run();
        } else {
          const ext = file.name.split(".").pop()?.toLowerCase() || "";

          let defaultImg = "/upload_default.png"; // 기본값

          if (ext === "pdf") defaultImg = "/upload_default.png";
          else if (["doc", "docx"].includes(ext)) defaultImg = "/upload_default.png";
          else if (["xls", "xlsx"].includes(ext)) defaultImg = "/upload_default.png";
          else if (["ppt", "pptx"].includes(ext)) defaultImg = "/upload_default.png";

          editor.chain().focus().setImage({ src: defaultImg }).run();
        }

        // 파일 입력 초기화
        if (fileInputRef.current) {
          fileInputRef.current.value = "";
        }
      }
    },
    [editor],
  );

  const addImage = useCallback(() => {
    fileInputRef.current?.click(); // 숨겨진 input 클릭
  }, []);

  if (!editor) {
    return null;
  }

  return (
    <div className="chat-text-area">
      <input
        ref={fileInputRef}
        type="file"
        accept="image/*, .pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx"
        onChange={handleFileSelect}
        style={{ display: "none" }}
      />
      <div className="toolbar-container">
        <ToolBar editor={editor} setLink={setLink} addImage={addImage} />
      </div>
      <div className="editor-container">
        <EditorContent editor={editor} />
      </div>
    </div>
  );
};

결과

요즘들어 ai 의존도가 다시 높아졌다. 이 부분이 염려된다.

profile
취업 준비생 낚곰입니다!! 반갑습니다!!

0개의 댓글