본문 바로가기
개발/React

리액트 - Toast UI 에디터로 이미지를 포함한 깃허브 마크다운 저장하기 (Toast UI Markdown Editor)

by 피로물든딸기 2024. 1. 17.
반응형

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

 

리액트 전체 링크

Git / GitHub 전체 링크

 

참고

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

 

깃허브 리포지토리 이미지 불러오기
깃허브 API로 이미지 업로드하기
깃허브에 업로드된 이미지 삭제하기
캡처한 이미지를 깃허브에 업로드하기
캡처한 이미지 여러 개 업로드하기
Toast UI 에디터로 이미지를 포함한 깃허브 마크다운 저장하기

 

Toast UI Editor에서 캡처 이미지를 포함하여 깃허브의 README.md를 수정해 보자.

 

Toast UI 에디터로 깃허브 마크다운 저장하기를 참고하여 아래 코드에서 시작하자.

이 코드는 auto-test 리포지토리의 README.md를 편집할 수 있다.

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 = process.env.REACT_APP_MY_TOKEN;

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: "600px",
        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="600px"
            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;

이미지 링크 설정하기 (blob vs raw)

 

먼저 깃허브 리포지토리에 저장된 이미지(아래 링크)를 README.md에서 불러보자.

https://github.com/bloodstrawberry/auto-test/blob/main/images/bloodstrawberry.jpg

 

위의 링크는 blob이고 raw로 변경한 링크도 README.md에 추가해 보자.

 

깃허브에서는 모두 정상적으로 이미지를 불러온다.

 

하지만 Toast UI Editor에서는 raw로 설정한 링크만 정상적으로 불러온다.

 

 

따라서 Toast UI Editor를 위해 raw로만 링크를 저장한다.

image test raw   
![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/bloodstrawberry.jpg)

캡처된 이미지 저장하기

 

Toast UI Editor에는 이미 캡처된 이미지를 붙여 넣을 수 있는 기능이 있다.

 

Base64로 인코딩 된 이미지 데이터를 Markdown 탭에서 확인할 수 있다.

image test raw

![image.png](data:image/png;base64,iVBORw0KGgoAA...) << 캡처된 이미지

<br>
<br>
![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/bloodstrawberry.jpg)

 

실제 그대로 저장하면 깃허브에서는 이미지가 깨지고, 매우 많은 텍스트가 그대로 저장되어 용량도 매우 늘어난다

 

Toast UI Editor에서 README.md를 그대로 불러오면 잘 보인다.

하지만 파일 크기커지기 때문에 비효율적이다.

 

이제 Base64로 인코딩 된 이미지 데이터를 path 정보로 변경하고,

실제 이미지는 해당 리포지토리의 path에 저장해 보자.


구현

 

실제 캡처된 이미지는 "![image.png](data:image/png;base64,"로 시작하고 ")"로 끝난다.

해당 코드를 "![image](경로)"로 교체해 보자.

// 캡처된 이미지
![image.png](data:image/png;base64,iVBORw0KGgoAA...) 

// 기존 이미지
![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/bloodstrawberry.jpg)

 

또한 캡처된 이미지여러 개 있을 수 있으므로 이미지를 저장하는 시간_0, 1, 2 순으로 저장해 보자.

// 캡처된 이미지
![image.png](data:image/png;base64,iVBORw0KGgoAA...) 
![image.png](data:image/png;base64,...) 
![image.png](data:image/png;base64,...) 

=>

![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/현재_시간_1.jpg)
![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/현재_시간_2.jpg)
![image](https://github.com/bloodstrawberry/auto-test/raw/main/images/현재_시간_3.jpg)

 

먼저 현재 시간을 구하는 함수는 다음과 같다. (yyyy_mm_dd_hh_mm_ss 형식)

const getCurrentDateTime = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  const seconds = String(now.getSeconds()).padStart(2, '0');

  return `${year}_${month}_${day}_${hours}_${minutes}_${seconds}`;
}

 

그리고 정규 표현식을 이용하여 캡처된 이미지를 찾는다.

이때  "![image.png](data:image/png;base64,...이미지...)" 에서

 

matches는 전체 문자열 "![image.png](data:image/png;base64,...이미지...)" 가 되고,

이미지를 업로드하기 위해 "...이미지..."(imageDataArray) 는 따로 추출한다.

/g 옵션을 사용했기 때문에 match 되는 모든 값을 얻을 수 있다.

const extractDataUrlImages = (inputString) => {
  // 정규식을 사용하여 Data URL 형식 찾기
  const regex = /!\[image\.png\]\(data:image\/png;base64,([^)]*)\)/g;
  const matches = inputString.match(regex);

  if (matches) {
      const imageDataArray = matches.map(match => match.replace(regex, '$1'));
      return { matches, imageDataArray };
  } else {
      return { matches: [], imageDataArray: [] };
  }
}

 

저장하기 버튼은 아래와 같이 변경된다.

markDown에서 base64 text를 찾아서 링크로 변경하였다. (images 폴더 아래에 image_현재 시간_번호.jpg로 저장)

그리고 해당 값을 이미지 소스 경로로 변경한다.

이 내용을 fileWriteREADME.md에 저장하고, 실제 이미지 데이터는 githubUpload를 이용해서 업로드한다.

  const handleSave = () => {
    let currentTime = getCurrentDateTime();
    let markDownContent = editorRef.current.getInstance().getMarkdown();    
    let {matches, imageDataArray} = extractDataUrlImages(markDownContent);    
    
    // base64 text를 링크로 변경
    for(let index = 0; index < matches.length; index++) {
      const filePath = `images/image_${currentTime}_${index}.jpg`;
      markDownContent = markDownContent.replace(matches[index], `![image](https://github.com/bloodstrawberry/auto-test/raw/main/${filePath})`);
    }

    fileWrite(markDownContent);
    
    // 이미지 업로드
    githubUpload(imageDataArray, currentTime);
  };

 

githubUpload캡처한 이미지 여러 개 업로드하기를 참고하여 다음과 같이 구현하였다.

  const githubUpload = async (imageDataUrls, currentTime) => {
    if (imageDataUrls.length === 0) {
      console.error("Please paste an image first.");
      return;
    }

    for (let index = 0; index < imageDataUrls.length; index++) {
      const base64encoded = imageDataUrls[index];
      const filePath = `images/image_${currentTime}_${index}.jpg`;
      const apiURL = `https://api.github.com/repos/bloodstrawberry/auto-test/contents/${filePath}`;
      const accessToken = process.env.REACT_APP_MY_TOKEN;

      // fileWrite 후 3초 뒤에 실행하기 위해 앞으로 이동 (충돌 방지)
      await new Promise((resolve) => setTimeout(resolve, 3000)); 

      try {        
        const response = await axios.put(
          apiURL,
          {
            message: `Add image ${index + 1}`,
            content: base64encoded,
            branch: "main",
          },
          {
            headers: {
              Authorization: `token ${accessToken}`,
              "Content-Type": "application/json",
            },
          }
        );

        console.log(
          `Image ${index + 1} uploaded successfully:`,
          response.data.content.name
        );
      } catch (error) {
        console.error(`Error uploading image ${index + 1}:`, error);
      }
    }
  };

 

에디터에 그림을 캡처하고 저장한 후, README.md이미지가 제대로 업로드 및 반영되는지 확인해 보자.

 

전체 코드는 다음과 같다.

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";

import axios from "axios";

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 = process.env.REACT_APP_MY_TOKEN;

const getCurrentDateTime = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  const seconds = String(now.getSeconds()).padStart(2, '0');

  return `${year}_${month}_${day}_${hours}_${minutes}_${seconds}`;
}

const extractDataUrlImages = (inputString) => {
  // 정규식을 사용하여 Data URL 형식 찾기
  const regex = /!\[image\.png\]\(data:image\/png;base64,([^)]*)\)/g;
  const matches = inputString.match(regex);

  if (matches) {
      const imageDataArray = matches.map(match => match.replace(regex, '$1'));
      return { matches, imageDataArray };
  } else {
      return { matches: [], imageDataArray: [] };
  }
}

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 githubUpload = async (imageDataUrls, currentTime) => {
    if (imageDataUrls.length === 0) {
      console.error("Please paste an image first.");
      return;
    }

    for (let index = 0; index < imageDataUrls.length; index++) {
      const base64encoded = imageDataUrls[index];
      const filePath = `images/image_${currentTime}_${index}.jpg`;
      const apiURL = `https://api.github.com/repos/bloodstrawberry/auto-test/contents/${filePath}`;
      const accessToken = process.env.REACT_APP_MY_TOKEN;

      // fileWrite 후 3초 뒤에 실행하기 위해 앞으로 이동 (충돌 방지)
      await new Promise((resolve) => setTimeout(resolve, 3000)); 

      try {        
        const response = await axios.put(
          apiURL,
          {
            message: `Add image ${index + 1}`,
            content: base64encoded,
            branch: "main",
          },
          {
            headers: {
              Authorization: `token ${accessToken}`,
              "Content-Type": "application/json",
            },
          }
        );

        console.log(
          `Image ${index + 1} uploaded successfully:`,
          response.data.content.name
        );
      } catch (error) {
        console.error(`Error uploading image ${index + 1}:`, error);
      }
    }
  };

  const handleSave = () => {
    let currentTime = getCurrentDateTime();
    let markDownContent = editorRef.current.getInstance().getMarkdown();    
    let {matches, imageDataArray} = extractDataUrlImages(markDownContent);    
    
    // base64 text를 링크로 변경
    for(let index = 0; index < matches.length; index++) {
      const filePath = `images/image_${currentTime}_${index}.jpg`;
      markDownContent = markDownContent.replace(matches[index], `![image](https://github.com/bloodstrawberry/auto-test/raw/main/${filePath})`);
    }

    fileWrite(markDownContent);
    
    // 이미지 업로드
    githubUpload(imageDataArray, currentTime);
  };

  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: "600px",
        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="600px"
            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;
반응형

댓글