본문 바로가기
개발/React

리액트 - Toast UI 에디터로 깃허브 마크다운 저장하기 (Toast UI Editor with GitHub RESTful API)

by 피로물든딸기 2023. 7. 30.
반응형

깃허브 데스크탑으로 프로젝트 관리하기 강의 오픈!! (인프런 바로가기)

 

리액트 전체 링크

Git / GitHub 전체 링크

 

참고

- RESTful API로 파일 읽기
RESTful API로 파일 쓰기

- 깃허브 RESTful API로 파일 편집기 만들기

- 파일 편집 후 메일 알림이 가도록 수정하기

 

- Toast UI로 에디터 만들기

- 글자색 변경 플러그인 추가하기

- 테이블 병합 플러그인 추가하기

- 에디터 저장하고 초기화하기

- Toast UI 에디터로 깃허브 마크다운 저장하기

- Toast UI 에디터로 이미지를 포함한 깃허브 마크다운 저장하기

- Socket.IO로 Toast UI Editor 동시 편집하기

 

토스트 에디터 예제 링크

 

토스트 UI 에디터로 로컬이 아닌 깃허브에 직접 파일을 저장하고 편집해 보자.

https://github.com/bloodstrawberry/auto-test/blob/main/README.md

 

먼저 게시글을 보는 모드(Viewer)편집 모드(Edit)를 구분하자.

 

Editor가 아닌 편집된 내용(게시글)만 보려면 Viewer를 이용하면 된다.

// Toast UI Editor
import "@toast-ui/editor/dist/toastui-editor.css";
import '@toast-ui/editor/dist/toastui-editor-viewer.css'; // Viewer css
import { Editor, Viewer } from "@toast-ui/react-editor";

 

하지만 이 방법으로 Viewer를 불러도 ref가 동작하지 않아 setMarkdown이 적용되지 않는다.

  <Viewer
    ref={editorRef} // 미동작
    initialValue={initContents} 
    usageStatistics={false}
    plugins={[tableMergedCell]}
  />

 

따라서 Viewer를 new로 생성하는 방법을 이용한다.

아래와 같이 import 구문을 수정하자.

// Toast UI Editor
import "@toast-ui/editor/dist/toastui-editor.css";
import "@toast-ui/editor/dist/toastui-editor-viewer.css"; // Viewer css
import { Editor } from "@toast-ui/react-editor";
import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer';

 

useEffect에서 viewer를 생성하면 된다.

해당되는 el에 알맞은 toast-editor-viewr class를 가지는 div 태그를 추가해둬야 한다.

  useEffect(() => {
    let item = localStorage.getItem(CONTENT_KEY);

    const viewer = new Viewer({
      el: document.querySelector(".toast-editor-viewer"),
      viewer: true,
      height: "400px",
      usageStatistics: false, // 통계 수집 거부
      plugins:[tableMergedCell],
    });

    viewer.setMarkdown(item);
    
    if (item) {
      editorRef.current.getInstance().setMarkdown(item);
      setInitContents(item);
    }
  }, []);

 

여기서는 Editor 위에 아래와 같이 추가하였다.

  <div className="toast-editor-viewer"></div>
  <Editor
    ref={editorRef}

 

이제 에디터 위에 뷰어가 생성되었다.

 

이제 편집하기 버튼을 만들어 기본 설정으로는 Viewer를 보여주고, 편집 버튼을 누르면 Editor로 전환해 보자.

const [editMode, setEditMode] = useState(false);

 

편집하기 버튼의 클릭 이벤트에 setEditMode를 추가한다.

  <Button
    variant="outlined"
    color="secondary"
    sx={{ m: 1 }}
    onClick={() => setEditMode(!editMode)}
  >
    {editMode ? "취소하기" : "편집하기"}
  </Button>

 

editMode에 따라 ViewerEditor 렌더링을 구분한다.

  {editMode === false && <div className="toast-editor-viewer"></div>}

  {editMode === true && (
    <Editor
      ref={editorRef}
      // initialValue={initContents}
      height="400px"
      placeholder="Please Enter Text."
      previewStyle="tab" // or vertical
      initialEditType="wysiwyg" // or markdown
      // hideModeSwitch={true} // 하단 숨기기
      toolbarItems={[
        // 툴바 옵션 설정
        ["heading", "bold", "italic", "strike"],
        ["hr", "quote"],
        ["ul", "ol", "task", "indent", "outdent"],
        ["table", /* "image", */ "link"],
        ["code", "codeblock"],
      ]}
      //theme="dark"
      //useCommandShortcut={false} // 키보드 입력 컨트롤 방지 ex ctrl z 등
      usageStatistics={false} // 통계 수집 거부
      plugins={[[colorSyntax, colorSyntaxOptions], tableMergedCell]}
    />
  )}

 

editMode에 따라 렌더링되는 태그가 달라지기 때문에 방어코드를 추가한다.

그리고 editMode가 변경될 때마다 useEffect가 실행되도록 [editMode]를 추가한다.

  useEffect(() => {
    let item = localStorage.getItem(CONTENT_KEY);

    if(editMode === false) {
      const viewer = new Viewer({
        el: document.querySelector(".toast-editor-viewer"),
        viewer: true,
        height: "400px",
        usageStatistics: false, // 통계 수집 거부
        plugins: [tableMergedCell],
      });
  
      viewer.setMarkdown(item);
    }
    
    if (item) {
      if(editorRef.current) editorRef.current.getInstance().setMarkdown(item);
    }
  }, [editMode]);

 

저장하기 버튼editMode인 경우에만 활성화되도록 disabled 옵션을 추가하자.

  <Button
    variant="outlined"
    color="primary"
    sx={{ m: 1 }}
    onClick={handleSave}
    disabled={editMode === false}
  >
    저장하기
  </Button>

 

이제 게시글을 편집하고 저장하는 기능까지 완성되었다.

 

전체 코드는 다음과 같다.

import React, { useEffect, useRef, useState } from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";

// Toast UI Editor
import "@toast-ui/editor/dist/toastui-editor.css";
import "@toast-ui/editor/dist/toastui-editor-viewer.css"; // Viewer css
import { Editor } from "@toast-ui/react-editor";
import Viewer from "@toast-ui/editor/dist/toastui-editor-viewer";

// Dark Theme 적용
// import '@toast-ui/editor/dist/toastui-editor.css';
// import '@toast-ui/editor/dist/theme/toastui-editor-dark.css';

// Color Syntax Plugin
import "tui-color-picker/dist/tui-color-picker.css";
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
import colorSyntax from "@toast-ui/editor-plugin-color-syntax";

// Table Merged Cell Plugin
import "@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css";
import tableMergedCell from "@toast-ui/editor-plugin-table-merged-cell";

const colorSyntaxOptions = {
  preset: [
    "#333333", "#666666", "#FFFFFF", "#EE2323", "#F89009", "#009A87", "#006DD7", "#8A3DB6",
    "#781B33", "#5733B1", "#953B34", "#FFC1C8", "#FFC9AF", "#9FEEC3", "#99CEFA", "#C1BEF9",
  ],
};

const CONTENT_KEY = "CONTENT_KEY";

const App = () => {
  const editorRef = useRef(null);
  const [editMode, setEditMode] = useState(false);

  const handleSave = () => {
    let markDownContent = editorRef.current.getInstance().getMarkdown();
    //let htmlContent = editorRef.current.getInstance().getHTML();
    console.log(markDownContent);
    localStorage.setItem(CONTENT_KEY, markDownContent);
  };

  useEffect(() => {
    let item = localStorage.getItem(CONTENT_KEY);

    if (editMode === false) {
      const viewer = new Viewer({
        el: document.querySelector(".toast-editor-viewer"),
        viewer: true,
        height: "400px",
        usageStatistics: false, // 통계 수집 거부
        plugins: [tableMergedCell],
      });

      viewer.setMarkdown(item);
    }

    if (item) {
      if (editorRef.current) editorRef.current.getInstance().setMarkdown(item);
    }
  }, [editMode]);

  return (
    <div>
      <Box sx={{ m: 2 }}>
        <h1>Toast UI Editor</h1>
        <Button
          variant="outlined"
          color="secondary"
          sx={{ m: 1 }}
          onClick={() => setEditMode(!editMode)}
        >
          {editMode ? "취소하기" : "편집하기"}
        </Button>
        <Button
          variant="outlined"
          color="primary"
          sx={{ m: 1 }}
          onClick={handleSave}
          disabled={editMode === false}
        >
          저장하기
        </Button>

        {editMode === false && <div className="toast-editor-viewer"></div>}

        {editMode === true && (
          <Editor
            ref={editorRef}
            // initialValue={initContents}
            height="400px"
            placeholder="Please Enter Text."
            previewStyle="tab" // or vertical
            initialEditType="wysiwyg" // or markdown
            // hideModeSwitch={true} // 하단 숨기기
            toolbarItems={[
              // 툴바 옵션 설정
              ["heading", "bold", "italic", "strike"],
              ["hr", "quote"],
              ["ul", "ol", "task", "indent", "outdent"],
              ["table", /* "image", */ "link"],
              ["code", "codeblock"],
            ]}
            //theme="dark"
            //useCommandShortcut={false} // 키보드 입력 컨트롤 방지 ex ctrl z 등
            usageStatistics={false} // 통계 수집 거부
            plugins={[[colorSyntax, colorSyntaxOptions], tableMergedCell]}
          />
        )}
      </Box>
    </div>
  );
};

export default App;

 

여기까지 구현 사항은 링크에서 확인해 보자.


깃허브에 저장하기

 

위에서 적용할 내용을 아래의 README.md에 저장해 보자.

https://github.com/bloodstrawberry/auto-test/blob/main/README.md

 

깃허브의 RESTful API로 파일을 읽고, 파일을 쓰는 코드를 추가하면 된다.

  const getSHA = async (octokit) => {
    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
      }
    );

    return result.data.sha;
  };

  const fileWrite = async (contents) => {
    const octokit = new Octokit({
      auth: myKey,
    });

    const currentSHA = await getSHA(octokit);
    const result = await octokit.request(
      `PUT /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        message: "commit message!",
        sha: currentSHA,
        committer: {
          name: "bloodstrawberry",
          email: "bloodstrawberry@github.com",
        },
        content: `${btoa(unescape(encodeURIComponent(`${contents}`)))}`,
        headers: {
          "X-GitHub-Api-Version": "2022-11-28",
        },
      }
    );

    console.log(result.status);
  };

  const fileRead = async () => {
    const octokit = new Octokit({
      auth: myKey,
    });

    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        encoding: "utf-8",
        decoding: "utf-8",
      }
    );

    return result;
  };

 

위의 코드를 사용하기 위해 useEffect를 아래와 같이 수정한다.

  const initReadMe = async () => {
    // let item = localStorage.getItem(CONTENT_KEY);
    let result = await fileRead();
    let contents = decodeURIComponent(escape(window.atob(result.data.content)));
    console.log(contents);

    if (editMode === false) {
      const viewer = new Viewer({
        el: document.querySelector(".toast-editor-viewer"),
        viewer: true,
        height: "400px",
        usageStatistics: false, // 통계 수집 거부
        plugins: [tableMergedCell],
      });

      viewer.setMarkdown(contents);
    }

    if (editorRef.current)
      editorRef.current.getInstance().setMarkdown(contents);
  };

  useEffect(() => {
    initReadMe();
  }, [editMode]);

 

handleSave에는 fileWrite를 추가한다.

  const handleSave = () => {
    let markDownContent = editorRef.current.getInstance().getMarkdown();
    //let htmlContent = editorRef.current.getInstance().getHTML();
    console.log(markDownContent);
    //localStorage.setItem(CONTENT_KEY, markDownContent);
    fileWrite(markDownContent);
  };

 

정상적으로 깃허브에 반영되고 불러오는 것을 확인해 보자.

 

전체 코드는 다음과 같다.

import React, { useEffect, useRef, useState } from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";

// GitHub RESTful API
import { Octokit } from "@octokit/rest";

// Toast UI Editor
import "@toast-ui/editor/dist/toastui-editor.css";
import "@toast-ui/editor/dist/toastui-editor-viewer.css"; // Viewer css
import { Editor } from "@toast-ui/react-editor";
import Viewer from "@toast-ui/editor/dist/toastui-editor-viewer";

// Dark Theme 적용
// import '@toast-ui/editor/dist/toastui-editor.css';
// import '@toast-ui/editor/dist/theme/toastui-editor-dark.css';

// Color Syntax Plugin
import "tui-color-picker/dist/tui-color-picker.css";
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
import colorSyntax from "@toast-ui/editor-plugin-color-syntax";

// Table Merged Cell Plugin
import "@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css";
import tableMergedCell from "@toast-ui/editor-plugin-table-merged-cell";

const colorSyntaxOptions = {
  preset: [
    "#333333", "#666666", "#FFFFFF", "#EE2323", "#F89009", "#009A87", "#006DD7", "#8A3DB6",
    "#781B33", "#5733B1", "#953B34", "#FFC1C8", "#FFC9AF", "#9FEEC3", "#99CEFA", "#C1BEF9",
  ],
};

//const CONTENT_KEY = "CONTENT_KEY";

let myKey = "...";

const App = () => {
  const editorRef = useRef(null);
  const [editMode, setEditMode] = useState(false);
  const repo = "auto-test";
  const path = "README.md";

  const getSHA = async (octokit) => {
    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
      }
    );

    return result.data.sha;
  };

  const fileWrite = async (contents) => {
    const octokit = new Octokit({
      auth: myKey,
    });

    const currentSHA = await getSHA(octokit);
    const result = await octokit.request(
      `PUT /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        message: "commit message!",
        sha: currentSHA,
        committer: {
          name: "bloodstrawberry",
          email: "bloodstrawberry@github.com",
        },
        content: `${btoa(unescape(encodeURIComponent(`${contents}`)))}`,
        headers: {
          "X-GitHub-Api-Version": "2022-11-28",
        },
      }
    );

    console.log(result.status);
  };

  const fileRead = async () => {
    const octokit = new Octokit({
      auth: myKey,
    });

    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        encoding: "utf-8",
        decoding: "utf-8",
      }
    );

    return result;
  };

  const handleSave = () => {
    let markDownContent = editorRef.current.getInstance().getMarkdown();
    //let htmlContent = editorRef.current.getInstance().getHTML();
    console.log(markDownContent);
    //localStorage.setItem(CONTENT_KEY, markDownContent);
    fileWrite(markDownContent);
  };

  const initReadMe = async () => {
    // let item = localStorage.getItem(CONTENT_KEY);
    let result = await fileRead();
    let contents = decodeURIComponent(escape(window.atob(result.data.content)));
    console.log(contents);

    if (editMode === false) {
      const viewer = new Viewer({
        el: document.querySelector(".toast-editor-viewer"),
        viewer: true,
        height: "400px",
        usageStatistics: false, // 통계 수집 거부
        plugins: [tableMergedCell],
      });

      viewer.setMarkdown(contents);
    }

    if (editorRef.current)
      editorRef.current.getInstance().setMarkdown(contents);
  };

  useEffect(() => {
    initReadMe();
  }, [editMode]);

  return (
    <div>
      <Box sx={{ m: 2 }}>
        <h1>Toast UI Editor</h1>
        <Button
          variant="outlined"
          color="secondary"
          sx={{ m: 1 }}
          onClick={() => setEditMode(!editMode)}
        >
          {editMode ? "취소하기" : "편집하기"}
        </Button>
        <Button
          variant="outlined"
          color="primary"
          sx={{ m: 1 }}
          onClick={handleSave}
          disabled={editMode === false}
        >
          저장하기
        </Button>

        {editMode === false && <div className="toast-editor-viewer"></div>}

        {editMode === true && (
          <Editor
            ref={editorRef}
            // initialValue={initContents}
            height="400px"
            placeholder="Please Enter Text."
            previewStyle="tab" // or vertical
            initialEditType="wysiwyg" // or markdown
            // hideModeSwitch={true} // 하단 숨기기
            toolbarItems={[
              // 툴바 옵션 설정
              ["heading", "bold", "italic", "strike"],
              ["hr", "quote"],
              ["ul", "ol", "task", "indent", "outdent"],
              ["table", /* "image", */ "link"],
              ["code", "codeblock"],
            ]}
            //theme="dark"
            //useCommandShortcut={false} // 키보드 입력 컨트롤 방지 ex ctrl z 등
            usageStatistics={false} // 통계 수집 거부
            plugins={[[colorSyntax, colorSyntaxOptions], tableMergedCell]}
          />
        )}
      </Box>
    </div>
  );
};

export default App;

 

참고 : 최신 정보를 유지할 필요가 있다면 IndexedDB로 최신 정보를 저장하자.

반응형

댓글