[toast-ui eidtor/React/Typescript] toast-ui eidtor ์ปค์Šคํ…€ํ•˜๊ธฐ (+ ์ด๋ฏธ์ง€ ์ €์žฅ, youtube/vimeo ๋งํฌ ๋ณ€ํ™˜)

๐Ÿค” ์„œ๋ก 

ํšŒ์‚ฌ ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•  ์—๋””ํ„ฐ๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค. ์ด์ „์— CK์—๋””ํ„ฐ๋ฅผ ์‚ฌ์šฉํ•ด๋ณธ ์ ์ด ์žˆ๋Š”๋ฐ, ์ด์ƒํ•˜๊ฒŒ๋„ ๋ฒ„์ „ ๋ฌธ์ œ์ธ์ง€ CK์—๋””ํ„ฐ๊ฐ€ ์ž‘๋™ํ•˜์ง€ ์•Š์•˜๊ณ , ์ฐจ์„ ์ฑ…์œผ๋กœ quill์„ ์‚ฌ์šฉํ•ด๋ดค์œผ๋‚˜ ๊ตฌํ˜„ ํ›„ ๋นŒ๋“œ๋ฅผ ํ•ด๋ณด๋‹ˆ import์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ตœ์ข…์ ์œผ๋กœ๋Š” toast-ui editor๋ฅผ ์‚ฌ์šฉํ–ˆ๊ณ , ์ง์ ‘ ํˆด๋ฐ”์˜ ์•„์ดํ…œ์„ ์ž์œ ๋กญ๊ฒŒ ์ปค์Šคํ…€ํ•  ์ˆ˜ ์žˆ๋Š” ๋งŒํผ ์—†๋Š” ๊ธฐ๋Šฅ์ด ๋งŽ์•„ ๊ณ ์ƒ์„ ํ–ˆ๊ธฐ์— ์ด ๊ณผ์ •์„ ๊ธ€๋กœ ๋‚จ๊ธด๋‹ค.

๐Ÿฅ… ๋ชฉํ‘œ

์ด๋ฏธ์ง€ ์ €์žฅ, youtube/viemo ๋…ธ์ถœ(iframe)์ด ๊ฐ€๋Šฅํ•œ toast-ui editor๋กœ ์ปค์Šคํ…€ํ•˜๊ธฐ

๐Ÿ‘ฉ๐Ÿปโ€๐Ÿ’ป ๊ตฌํ˜„

* {{}} ์ด๋ ‡๊ฒŒ ๊ฐ์‹ผ ๋ถ€๋ถ„์€ ์ง์ ‘ ๋„ฃ์–ด์•ผ ํ•œ๋‹ค.

๊ฒฐ๊ณผ

etc-image-0

์ „์ฒด ์ฝ”๋“œ(์—๋””ํ„ฐ)

import React, { useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import axios from 'axios';
/* Toast-UI */
import colorSyntax from '@toast-ui/editor-plugin-color-syntax';
import fontSize from 'tui-editor-plugin-font-size';
import '@toast-ui/editor/dist/i18n/ko-kr';
import '@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import 'tui-color-picker/dist/tui-color-picker.css';
import { Editor } from '@toast-ui/react-editor';

import { theme } from '@/styles/theme';
interface CustomEditorProps {
  data: string;
  setData: React.Dispatch<React.SetStateAction<string>>;
  reset?: boolean;
  setReset?: React.Dispatch<React.SetStateAction<boolean>>;
}

const CustomEditor: React.FC<CustomEditorProps> = ({ data, setData, reset, setReset }) => {
  const editorRef = useRef<Editor>(null);
  const [isSet, setIsSet] = useState<boolean>(false);

  const toolbarItems = [['heading'], ['bold', 'italic', 'strike'], ['ul', 'ol'], ['image', 'link']];

  const handleSave = () => {
    let htmlContent = editorRef.current && editorRef.current.getInstance().getHTML();
    if (htmlContent) {
      setData(htmlContent);
    }
  };

  // ๋ฆฌ์…‹
  useEffect(() => {
    if (editorRef.current && reset && setReset) {
      editorRef.current.getInstance().setMarkdown('');
      setReset(false);
    }
  }, [reset]);

  // ์ดˆ๊ธฐ๊ฐ’ ์„ธํŒ…
  useEffect(() => {
    if (!isSet && editorRef.current) {
      editorRef.current.getInstance().setMarkdown(data ? data : '');
      setIsSet(true);
    }
  }, []);

  const [load, setLoad] = useState<number>(0);
  useEffect(() => {
    if (load < 2 && data && data != '<p><br></p>' && isSet && editorRef.current) {
      editorRef.current.getInstance().setMarkdown(data);
      setLoad((prev) => prev + 1);
    }
  }, [isSet, data]);

  return (
    <StEditor>
      <Editor
        initialEditType="wysiwyg"
        initialValue={data}
        previewStyle="vertical"
        onChange={handleSave}
        ref={editorRef}
        toolbarItems={toolbarItems}
        hideModeSwitch={true}
        plugins={[colorSyntax, fontSize]}
        placeholder="๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”"
        language="ko-KR"
        hooks={{
          addImageBlobHook: (blob: any, callback: any) => {
            const formData = new FormData();
            formData.append('upload', blob);
            // ์ด๋ฏธ์ง€๋ฅผ ์„œ๋ฒ„์— ์—…๋กœ๋“œํ•˜๋Š” ์š”์ฒญ
            axios
              .post({{'์š”์ฒญ URL'}}, formData, {
                headers: {
                  'Content-Type': 'multipart/form-data',
                  authorization: {{ํ† ํฐ}},
                  ClientSecret: {{ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ}},
                },
              })
              .then((response) => {
                // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์„ฑ๊ณต ์‹œ ์ด๋ฏธ์ง€ URL์„ ์ฝœ๋ฐฑ ํ•จ์ˆ˜์— ์ „๋‹ฌํ•˜์—ฌ ์—๋””ํ„ฐ์— ํ‘œ์‹œ
                const imageUrl = response.data.url;
                callback(imageUrl, '์‚ฌ์ง„ ๋Œ€์ฒด ํ…์ŠคํŠธ ์ž…๋ ฅ');
              })
              .catch((error) => {
                // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ ์‹œ ์—๋””ํ„ฐ์— ์‹คํŒจ ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œ
                console.log(error);
                callback('image_load_fail', '์‚ฌ์ง„ ๋Œ€์ฒด ํ…์ŠคํŠธ ์ž…๋ ฅ');
              });
          },
        }}
      />
    </StEditor>
  );
};

export default CustomEditor;

const StEditor = styled.div`
  & .toastui-editor-contents {
    z-index: 0;
  }

  & p {
    font-size: 16px;
  }

  & h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    border: none;
  }

  // ํฐํŠธ ์‚ฌ์ด์ฆˆ ์ž…๋ ฅ์ฐฝ
  & .size-input {
    width: 100%;
  }

  & .drop-down-item {
    font-size: 0.6rem;
    padding: 0.5rem 0.2rem;
    cursor: pointer;
  }
`;

Props
๋ฐ›์•„์˜ค๋Š” ๊ฐ’์€ data, setData, reset, setRest ์ด๋ ‡๊ฒŒ 4๊ฐ€์ง€์ด๋‹ค.

  • data: ์—๋””ํ„ฐ ์•ˆ์— ์ ํžˆ๋Š” ๋‚ด์šฉ
  • setData: ์—๋””ํ„ฐ ์•ˆ์˜ ๋‚ด์šฉ์„ ๋ณ€๊ฒฝํ•˜๋Š” setState
  • reset(optional): ์ดˆ๊ธฐํ™” ํŠธ๋ฆฌ๊ฑฐ, default ๊ฐ’์€ false
  • setReset(optional): ์ดˆ๊ธฐํ™” ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ํ™œ์„ฑํ™”ํ•˜๊ธฐ ์œ„ํ•œ setState

reset๊ณผ setReset์€ toast-ui editor์—์„œ ์ดˆ๊ธฐ๊ฐ’์„ ์„ค์ •ํ•  ๋•Œ ์ด์ „ ๊ฐ’์ด ๋‚จ์•„์žˆ๋Š” ์ด์Šˆ๊ฐ€ ์žˆ์–ด์„œ ์ž„์˜๋กœ ๋ฆฌ์…‹์‹œํ‚ค๊ธฐ ์œ„ํ•ด ์ถ”๊ฐ€ํ–ˆ๋‹ค. ๋งŒ์•ฝ ์ดˆ๊ธฐ๊ฐ’ ์„ค์ •์ด ์ž˜ ๋œ๋‹ค๋ฉด ์‚ฌ์šฉํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค.

ํˆด๋ฐ”
ํˆด๋ฐ”์—๋Š” ๋‘๊ฐ€์ง€ ์š”์†Œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋Š”๋ฐ ๊ธ€์ž์ƒ‰ ๋ณ€๊ฒฝ๊ณผ ํฐํŠธ ํฌ๊ธฐ ๋ณ€๊ฒฝ์ด๋‹ค. colorSyntax๋Š” toast-ui์—์„œ ์ œ๊ณตํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ์ด๊ณ , fontSize๋Š” ๊นƒํ—™์—์„œ ๋‹ค๋ฅธ ๋ถ„์ด ๋งŒ๋“œ์‹  ๊ฑธ ์‚ฌ์šฉํ–ˆ๋‹ค. ์ง์ ‘ ๋งŒ๋“ค์–ด๋„ ๋˜์ง€๋งŒ ํ•„์ˆ˜๋กœ ์š”์ฒญ๋œ ์‚ฌํ•ญ์ด ์•„๋‹ˆ์—ˆ๊ธฐ์— ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์—ˆ๋‹ค. (toast-ui editor์—์„œ ํ”Œ๋Ÿฌ๊ทธ์ธ์œผ๋กœ ์ œ๊ณตํ•˜๋ฉด ๋” ์ข‹๊ฒ ์ง€๋งŒ)

etc-image-1
etc-image-2

์ดˆ๊ธฐ๊ฐ’ ์„ธํŒ…

  useEffect(() => {
    if (!isSet && editorRef.current) {
      editorRef.current.getInstance().setMarkdown(data ? data : '');
      setIsSet(true);
    }
  }, []);

  const [load, setLoad] = useState<number>(0);
  useEffect(() => {
    if (load < 2 && data && data != '<p><br></p>' && isSet && editorRef.current) {
      editorRef.current.getInstance().setMarkdown(data);
      setLoad((prev) => prev + 1);
    }
  }, [isSet, data]);

ํ•ด๋‹น ๋ถ€๋ถ„์€ ์ดˆ๊ธฐ๊ฐ’์„ ์„ธํŒ…ํ•˜๋Š” ๋ถ€๋ถ„์ธ๋ฐ ๋” ์ข‹์€ ์ฝ”๋“œ๊ฐ€ ์žˆ์„๊ฑฐ๋ผ ์ƒ๊ฐํ•œ๋‹ค. ์ฒ˜์Œ ๋ Œ๋”๋ง๋์„ ๋•Œ๋Š” isSet์˜ ๊ฐ’์ด false์ด๋‹ค. ์ด๋•Œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ๋ฐ์ดํ„ฐ๋กœ ์„ธํŒ…์„, ์•„๋‹ˆ๋ฉด ๋นˆ ์ŠคํŠธ๋ง์œผ๋กœ ์„ธํŒ…์„ ํ•˜๋ฉด์„œ isSet์„ true๋กœ ๋งŒ๋“ ๋‹ค.

์•„๋ž˜ useEffect๋Š” ์ฒ˜์Œ ์—๋””ํ„ฐ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ Œ๋”๋ง๋  ๋•Œ ๋ฐ์ดํ„ฐ๊ฐ€ 2๋ฒˆ ๋ณ€๊ฒฝ๋˜๋Š” ์ด์Šˆ ๋•Œ๋ฌธ์— ํ™•์‹คํ•˜๋„๋ก ์ดˆ๊ธฐ๊ฐ’ data๋ฅผ ๋‘ ๋ฒˆ ์„ธํŒ…ํ•˜๋ ค๊ณ  ๋งŒ๋“  ๋ถ€๋ถ„์ด๋‹ค. ๋น„ํšจ์œจ์ ์œผ๋กœ ๋ณด์ผ ์ˆ˜ ์žˆ์ง€๋งŒ ์ดˆ๊ธฐ๊ฐ’ ์„ธํŒ…์ด ์ž˜ ๋˜์ง€ ์•Š๋Š” ์ด์Šˆ ๋•Œ๋ฌธ์— ์ˆ˜์ •์„ 5๋ฒˆ์ •๋„๋Š” ํ•œ ๊ฒƒ ๊ฐ™๋‹ค.

์ด๋ฏธ์ง€ ์—…๋กœ๋“œ

 hooks={{
          addImageBlobHook: (blob: any, callback: any) => {
            const formData = new FormData();
            formData.append('upload', blob);
            // ์ด๋ฏธ์ง€๋ฅผ ์„œ๋ฒ„์— ์—…๋กœ๋“œํ•˜๋Š” ์š”์ฒญ
            axios
              .post({{'์š”์ฒญ URL'}}, formData, {
                headers: {
                  'Content-Type': 'multipart/form-data',
                  authorization: {{ํ† ํฐ}},
                  ClientSecret: {{ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ}},
                },
              })
              .then((response) => {
                // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์„ฑ๊ณต ์‹œ ์ด๋ฏธ์ง€ URL์„ ์ฝœ๋ฐฑ ํ•จ์ˆ˜์— ์ „๋‹ฌํ•˜์—ฌ ์—๋””ํ„ฐ์— ํ‘œ์‹œ
                const imageUrl = response.data.url;
                callback(imageUrl, '์‚ฌ์ง„ ๋Œ€์ฒด ํ…์ŠคํŠธ ์ž…๋ ฅ');
              })
              .catch((error) => {
                // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ ์‹œ ์—๋””ํ„ฐ์— ์‹คํŒจ ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œ
                callback('image_load_fail', '์‚ฌ์ง„ ๋Œ€์ฒด ํ…์ŠคํŠธ ์ž…๋ ฅ');
              });
          },
        }}

์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋Š” ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ ๋งŽ์€ ์ž๋ฃŒ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ด๋‹ค. ์šฐ๋ฆฌ๋Š” ์—๋””ํ„ฐ์˜ ์ด๋ฏธ์ง€๋ฅผ S3์— ์—…๋กœ๋“œํ•˜๋Š” ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— axios๋กœ S3์— ์—…๋กœ๋“œ ํ›„ ์ด๋ฏธ์ง€ url์„ ๋ฐ›์•˜๋‹ค.

์ „์ฒด์ฝ”๋“œ(์—๋””ํ„ฐ ๋‚ด์šฉ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ)

import '@/styles/Watermark.styles.css';

export const convertLink = (data: string | undefined) => {
  if (data) {
    const newData = data.replace(/<a\b[^>]*>(.*?)<\/a>/gi, (match: any) => {
      // <a> ํƒœ๊ทธ์˜ href ์†์„ฑ์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
      const hrefMatch = match.match(/href="([^"]*)"/);
      const href = hrefMatch ? hrefMatch[1] : '';

      // href๊ฐ€ Vimeo ๋™์˜์ƒ์˜ ๋งํฌ์ธ์ง€ ํ™•์ธ
      const isVimeoLink = href.includes('vimeo.com');
      const isYoutubeLinkShare = href.includes('youtu.be');
      const isYoutubeLink = href.includes('youtube.com/watch');

      if (isVimeoLink) {
        const videoIdMatch = href.match(/vimeo\.com\/(\d+)/);
        const videoId = videoIdMatch ? videoIdMatch[1] : '';
        const nickName = localStorage.getItem('nickName');
        return `
        <div style="position: relative;">
          <div class="watermark">${nickName}
          ${videoId}" class="vimeo_vid" width="100%" frameborder="0"></iframe>
        </div>
        `;
      } else if (isYoutubeLinkShare || isYoutubeLink) {
        const videoIdMatch = isYoutubeLinkShare
          ? href.match(/youtu\.be\/([^?&]+)/)
          : href.match(/[?&]v=([^?&]+)/);
        const videoId = videoIdMatch ? videoIdMatch[1] : '';
        return `${videoId}" width="640" height="360" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>`;
      } else {
        // Vimeo, Youtube ๋™์˜์ƒ ๋งํฌ๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋Š” <a> ํƒœ๊ทธ์— target="_blank"๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ๋ฐ˜ํ™˜
        return match.replace(/<a\b/gi, '<a target="_blank"');
      }
    });
    return newData;
  }
};

toast-ui editor์—์„œ๋Š” ์‚ฝ์ž…ํ•œ link๋ฅผ aํƒœ๊ทธ์˜ ํ˜•ํƒœ๋กœ ์ €์žฅํ•œ๋‹ค. ๊ทธ๋ ‡๊ธฐ ๋•Œ๋ฌธ์— ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ iframe์œผ๋กœ ๋ณด์—ฌ์ฃผ๊ณ  ์‹ถ์—ˆ๋˜ ๋‚˜๋กœ์„œ๋Š” aํƒœ๊ทธ๋ฅผ iframe์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ๊ณผ์ •์ด ํ•„์š”ํ–ˆ๋‹ค. youtube, viemo ๋‘ ๊ฐ€์ง€์˜ ์˜์ƒ ๋งํฌ๋งŒ ๋ณ€ํ™˜์„ ํ•˜๋ฉด ๋๊ธฐ ๋•Œ๋ฌธ์— a๋งํฌ์—์„œ youtube, viemo url์˜ ํ‚ค์›Œ๋“œ๊ฐ€ ์ถ”์ถœ๋˜๋Š”์ง€ ํ™•์ธํ•˜๊ณ  ๋ณ€ํ™˜์„ ํ–ˆ๋‹ค. (์œ ํŠœ๋ธŒ์˜ ๊ฒฝ์šฐ url์ž…๋ ฅ ํ•„๋“œ์—์„œ ๋ณต์‚ฌํ•  ์ˆ˜ ์žˆ๋Š” url๊ณผ ๊ณต์œ ํ•˜๊ธฐ ๋ฒ„ํŠผ์˜ url์ด ๋‹ฌ๋ผ ๋‘ ๊ฐ€์ง€ ๋ชจ๋‘ ํ™•์ธ์„ ํ•ด์ฃผ์—ˆ๋‹ค.

etc-image-3

 

etc-image-4

<div
	dangerouslySetInnerHTML={{       		
    	__html:convertLink(Dompurify.sanitize({{"๋‚ด์šฉ"}})) || '',
	}}
/>

์—๋””ํ„ฐ์˜ ๋‚ด์šฉ์€ html ํ˜•ํƒœ์ด๊ธฐ ๋•Œ๋ฌธ์— ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ๋Š” dangerouslySetInnerHTML์„ ์‚ฌ์šฉํ•ด์„œ ๋ถˆ๋Ÿฌ์™”๋‹ค.

๐Ÿค” ์™œ Dompurify.sanitize๋ฅผ ์‚ฌ์šฉํ• ๊นŒ?
1. XSS๋Š” ์•…์„ฑ ์‚ฌ์šฉ์ž๊ฐ€ ์›น ํŽ˜์ด์ง€์— ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์‚ฝ์ž…ํ•˜์—ฌ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์—๊ฒŒ ์‹คํ–‰๋˜๋„๋ก ํ•˜๋Š” ๊ณต๊ฒฉ์ด๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๊ณต๊ฒฉ์ž๋Š” ์‚ฌ์šฉ์ž์˜ ์„ธ์…˜์„ ํƒˆ์ทจํ•˜๊ฑฐ๋‚˜, ์•…์„ฑ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰์‹œํ‚ค๊ฑฐ๋‚˜, ๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ํƒˆ์ทจํ•  ์ˆ˜ ์žˆ๋‹ค.
DOMPurify๋Š” ์‚ฌ์šฉ์ž ์ž…๋ ฅ ํ˜น์€ ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ์—์„œ ์ž ์žฌ์ ์œผ๋กœ ์œ„ํ—˜ํ•œ ์Šคํฌ๋ฆฝํŠธ ํƒœ๊ทธ ๋ฐ ์†์„ฑ์„ ์ œ๊ฑฐํ•˜์—ฌ XSS ๊ณต๊ฒฉ์„ ๋ฐฉ์ง€ํ•œ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด, <script> ํƒœ๊ทธ๋‚˜ onmouseover์™€ ๊ฐ™์€ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์†์„ฑ ๋“ฑ์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.
2. ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ์ข…์ข… ์™ธ๋ถ€ API๋‚˜ ์‚ฌ์šฉ์ž ์ž…๋ ฅ์„ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค. ์ด๋Ÿฌํ•œ ๋ฐ์ดํ„ฐ๋Š” ์‹ ๋ขฐํ•  ์ˆ˜ ์—†์œผ๋ฉฐ, ์•…์˜์ ์ธ ๋‚ด์šฉ์ด ํฌํ•จ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
DOMPurify๋Š” ์ด๋Ÿฌํ•œ ์‹ ๋ขฐํ•  ์ˆ˜ ์—†๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์ •ํ™”ํ•˜์—ฌ, ์•…์„ฑ ์ฝ”๋“œ๊ฐ€ ํฌํ•จ๋œ HTML์ด ์›น ํŽ˜์ด์ง€์— ์‚ฝ์ž…๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•œ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋กœ๋ถ€ํ„ฐ ์˜ค๋Š” ๋ณด์•ˆ ์œ„ํ˜‘์„ ์ค„์ผ ์ˆ˜ ์žˆ๋‹ค.

'L > Javascript' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

[react-datepicker] datepicker ์ปค์Šคํ…€ํ•˜๊ธฐ (+ timepicker)  (0) 2024.07.18