본문 바로가기
개발/React

리액트 - useNavigate로 Toast UI Editor 이동하기 (Toast UI Editor with Chonky Browser)

by 피로물든딸기 2024. 3. 16.
반응형

리액트 전체 링크

 

참고 

- Toast UI로 에디터 만들기

- RESTful API로 파일 읽기

- URL query string 받아오기

 

- 파일 브라우저 만들기
- chonky 기본 설정 확인하기
- 액션 추가하고 다크 모드 구현하기
커스텀 액션 추가하기
- Chonky 파일 맵 만들기
- 리포지토리의 폴더 정보 저장하기
- 깃허브 리포지토리를 파일 브라우저로 불러오기
useNavigate로 Toast UI Editor 이동하기

 

아래 결과는 링크에서 확인할 수 있다

 

이제 마크다운(.md) 파일더블 클릭하면 선택된 파일Toast UI Editor에서 열어보자.

 

이미 파일을 여는 액션이 정의되어 있으므로 targetFile.name의 확장자가 .md인 경우에 대해 코드를 추가한다.

  const useFileActionHandler = (
  ) => {
    return useCallback(
      (data) => {
        if (data.id === ChonkyActions.OpenFiles.id) {
          const { targetFile, files } = data.payload;
          const fileToOpen = targetFile || files[0];
                   
          if (fileToOpen && FileHelper.isDirectory(fileToOpen)) {
            setCurrentFolderId(fileToOpen.id);
            return;
          }

          if(targetFile.name.endsWith(".md")) {
            let fileNames = data.state.selectedFiles.map((item) => item.name);
            let dirPath = folderChain.map((item) => item.name).join("/");
            let filePath = `${dirPath}/${fileNames}`;

            ... // Toast UI Editor로 이동
          }

 

다른 라우터로 이동하기 위해서 useNavigate를 사용한다. (/tui-editor에 토스트 UI Editor는 구현되어 있다고 가정)

navigate는 현재 state를 넘겨줄 수 있으므로, filePath를 state에 담아 넘긴다.

import { useNavigate } from 'react-router-dom';

const ChonkyBrowser = React.memo((props) => {
  ...
  const navigate  = useNavigate();
  
  
  ...
  
  
  const useFileActionHandler = (
    ...
  ) => {
    return useCallback(
      (data) => {
        if (data.id === ChonkyActions.OpenFiles.id) {
          ...
          if(targetFile.name.endsWith(".md")) {
            let fileNames = data.state.selectedFiles.map((item) => item.name);
            let dirPath = folderChain.map((item) => item.name).join("/");
            let filePath = `${dirPath}/${fileNames}`;

            navigate("/tui-editor", { state: { filePath } });
          }

 

Toast UI Editorinit에서 location.state에 값이 있는 경우, RESTful API를 이용하여 파일을 읽는다.

  const init = async() => {
    let item = localStorage.getItem(CONTENT_KEY);
    if(location.state) {
      let filePath = location.state.filePath;
      let result = await gh.fileRead(`actions/${filePath}`);
      if(result !== undefined) item = result;
    }
    
    ...

    if (item) {
      if (editorRef.current) editorRef.current.getInstance().setMarkdown(item);
    } else {
      if (editorRef.current)
        editorRef.current.getInstance().setMarkdown(initData);
    }
  }

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

새 창으로 열기

 

위의 구현은 뒤로 가기 버튼을 누른 후, 다시 브라우저에 재진입해야 하는 불편함이 있다.

따라서 새 창으로 열도록 코드를 수정해 보자.

 

아쉽게도 useNavigate새 창으로 여는 옵션이 없다.

반대로 window.open state를 전달할 방법이 없다.

 

따라서 localStoragequery string을 이용해야 한다.

 

여기서는 localStoragefilePath를 저장하도록 수정하였다.

  // navigate("/tui-editor", { state: { filePath } });
  localStorage.setItem("FILE_PATH", JSON.stringify({ filePath }));
  window.open("/react-project/tui-editor", "_blank");

 

토스트 UI 에디터init에서 filePath가 존재하면 API를 이용해 파일을 읽는다.

  const init = async() => {
    let item = localStorage.getItem(CONTENT_KEY);
    let open = localStorage.getItem("FILE_PATH");
    
    // if(location.state) {
    if(open) {
      // let filePath = location.state.filePath;
      let filePath = JSON.parse(open).filePath;
      let result = await gh.fileRead(`actions/${filePath}`);
      
      if(result !== undefined) item = result;
      
      localStorage.removeItem("FILE_PATH");
    }
    
    ...

 

이제 새 창으로 마크다운 에디터가 열리게 된다.


최종 코드는 다음과 같다.

 

ChonkyBrowser.js

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

import { useNavigate } from 'react-router-dom';

import {
  setChonkyDefaults,
  ChonkyActions,
  FileHelper,
  FullFileBrowser,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";
import { customActions } from "./myCustomActions";

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

const ChonkyBrowser = React.memo((props) => {
  const [darkMode, setDarkMode] = useState(false);
  const toggleDarkMode = () => {
    setDarkMode(!darkMode);
  };

  const fileInputRef = useRef(null);
  const navigate  = useNavigate();

  // const demoMap = require("./demo.json");
  const demoMap = props.chonkyMap;

  const prepareCustomFileMap = () => {
    const baseFileMap = demoMap.fileMap;
    const rootFolderId = demoMap.rootFolderId;
    return { baseFileMap, rootFolderId };
  };

  const useCustomFileMap = () => {
    const { baseFileMap, rootFolderId } = useMemo(prepareCustomFileMap, []);

    const [fileMap, setFileMap] = useState(baseFileMap);
    const [currentFolderId, setCurrentFolderId] = useState(rootFolderId);

    const resetFileMap = useCallback(() => {
      setFileMap(baseFileMap);
      setCurrentFolderId(rootFolderId);
    }, [baseFileMap, rootFolderId]);

    const currentFolderIdRef = useRef(currentFolderId);
    useEffect(() => {
      currentFolderIdRef.current = currentFolderId;
    }, [currentFolderId]);

    const deleteFiles = useCallback((files) => {
      setFileMap((currentFileMap) => {
        const newFileMap = { ...currentFileMap };
        files.forEach((file) => {
          delete newFileMap[file.id];

          if (file.parentId) {
            const parent = newFileMap[file.parentId];
            const newChildrenIds = parent.childrenIds.filter(
              (id) => id !== file.id
            );
            newFileMap[file.parentId] = {
              ...parent,
              childrenIds: newChildrenIds,
              childrenCount: newChildrenIds.length,
            };
          }
        });

        return newFileMap;
      });
    }, []);

    const moveFiles = useCallback((files, source, destination) => {
      setFileMap((currentFileMap) => {
        const newFileMap = { ...currentFileMap };
        const moveFileIds = new Set(files.map((f) => f.id));

        const newSourceChildrenIds = source.childrenIds.filter(
          (id) => !moveFileIds.has(id)
        );
        newFileMap[source.id] = {
          ...source,
          childrenIds: newSourceChildrenIds,
          childrenCount: newSourceChildrenIds.length,
        };

        const newDestinationChildrenIds = [
          ...destination.childrenIds,
          ...files.map((f) => f.id),
        ];
        newFileMap[destination.id] = {
          ...destination,
          childrenIds: newDestinationChildrenIds,
          childrenCount: newDestinationChildrenIds.length,
        };

        files.forEach((file) => {
          newFileMap[file.id] = {
            ...file,
            parentId: destination.id,
          };
        });

        return newFileMap;
      });
    }, []);

    const idCounter = useRef(0);
    const createFolder = useCallback((folderName) => {
      setFileMap((currentFileMap) => {
        const newFileMap = { ...currentFileMap };

        const newFolderId = `new-folder-${idCounter.current++}`;
        newFileMap[newFolderId] = {
          id: newFolderId,
          name: folderName,
          isDir: true,
          modDate: new Date(),
          parentId: currentFolderIdRef.current,
          childrenIds: [],
          childrenCount: 0,
        };

        const parent = newFileMap[currentFolderIdRef.current];
        newFileMap[currentFolderIdRef.current] = {
          ...parent,
          childrenIds: [...parent.childrenIds, newFolderId],
        };

        return newFileMap;
      });
    }, []);

    const createFile = useCallback((fileName) => {
      setFileMap((currentFileMap) => {
        const newFileMap = { ...currentFileMap };

        const newFolderId = `new-folder-${idCounter.current++}`;
        newFileMap[newFolderId] = {
          id: newFolderId,
          name: fileName,
          modDate: new Date(),
          parentId: currentFolderIdRef.current,
          childrenIds: [],
          childrenCount: 0,
        };

        const parent = newFileMap[currentFolderIdRef.current];
        newFileMap[currentFolderIdRef.current] = {
          ...parent,
          childrenIds: [...parent.childrenIds, newFolderId],
        };

        return newFileMap;
      });
    }, []);

    return {
      fileMap,
      currentFolderId,
      setCurrentFolderId,
      resetFileMap,
      deleteFiles,
      moveFiles,
      createFolder,
      createFile,
    };
  };

  const useFiles = (fileMap, currentFolderId) => {
    return useMemo(() => {
      const currentFolder = fileMap[currentFolderId];
      const childrenIds = currentFolder.childrenIds;
      const files = childrenIds.map((fileId) => fileMap[fileId]);
      return files;
    }, [currentFolderId, fileMap]);
  };

  const useFolderChain = (fileMap, currentFolderId) => {
    return useMemo(() => {
      const currentFolder = fileMap[currentFolderId];
      const folderChain = [currentFolder];
      let parentId = currentFolder.parentId;
      while (parentId) {
        const parentFile = fileMap[parentId];
        if (parentFile) {
          folderChain.unshift(parentFile);
          parentId = parentFile.parentId;
        } else {
          break;
        }
      }
      return folderChain;
    }, [currentFolderId, fileMap]);
  };

  const useFileActionHandler = (
    folderChain,
    setCurrentFolderId,
    deleteFiles,
    moveFiles,
    createFolder,
    toggleDarkMode,
    fileInputRef
  ) => {
    return useCallback(
      (data) => {
        if (data.id === ChonkyActions.OpenFiles.id) {
          const { targetFile, files } = data.payload;
          const fileToOpen = targetFile || files[0];
                   
          if (fileToOpen && FileHelper.isDirectory(fileToOpen)) {
            setCurrentFolderId(fileToOpen.id);
            return;
          }

          if(targetFile.name.endsWith(".md")) {
            let fileNames = data.state.selectedFiles.map((item) => item.name);
            let dirPath = folderChain.map((item) => item.name).join("/");
            let filePath = `${dirPath}/${fileNames}`;

            // navigate("/tui-editor", { state: { filePath } });
            localStorage.setItem("FILE_PATH", JSON.stringify({ filePath }));
            window.open("/react-project/tui-editor", "_blank");
          }
        } else if (data.id === ChonkyActions.DeleteFiles.id) {
          deleteFiles(data.state.selectedFilesForAction);
        } else if (data.id === ChonkyActions.MoveFiles.id) {
          moveFiles(
            data.payload.files,
            data.payload.source,
            data.payload.destination
          );
        } else if (data.id === ChonkyActions.CreateFolder.id) {
          const folderName = prompt("Provide the name for your new folder:");
          if (folderName) createFolder(folderName);
        } else if (data.id === ChonkyActions.ToggleDarkMode.id) {
          toggleDarkMode();
        } else if (data.id === "view") {
          let fileNames = data.state.selectedFiles.map((item) => item.name);
          let dirPath = folderChain.map((item) => item.name).join("/");

          for (let name of fileNames) {
            console.log(`${dirPath}/${name}`);
          }
        } else if (data.id === "upload") {
          fileInputRef.current.click();
        } 

        console.log(data);
      },
      [createFolder, deleteFiles, moveFiles, setCurrentFolderId, toggleDarkMode]
    );
  };

  const {
    fileMap,
    currentFolderId,
    setCurrentFolderId,
    deleteFiles,
    moveFiles,
    createFolder,
    createFile,
  } = useCustomFileMap();

  setChonkyDefaults({ iconComponent: ChonkyIconFA });

  const files = useFiles(fileMap, currentFolderId);
  const folderChain = useFolderChain(fileMap, currentFolderId);
  const handleFileAction = useFileActionHandler(
    folderChain,
    setCurrentFolderId,
    deleteFiles,
    moveFiles,
    createFolder,
    toggleDarkMode,
    fileInputRef
  );

  const fileActions = useMemo(
    () => [
      ...customActions,
      ChonkyActions.CreateFolder,
      ChonkyActions.DeleteFiles,
      // ChonkyActions.CopyFiles,
      // ChonkyActions.UploadFiles,
      // ChonkyActions.DownloadFiles,
      ChonkyActions.ToggleDarkMode,
    ],
    []
  );

  const thumbnailGenerator = useCallback(
    (file) =>
      file.thumbnailUrl ? `https://chonky.io${file.thumbnailUrl}` : null,
    []
  );

  const fileUpload = async (e) => {
    let files = e.target.files;
    for (let file of files) {
      if (file === undefined) continue;

      console.log(file.name); // 파일 이름

      createFile(file.name);
      // 실제 파일 내용 read
      // let fileReader = new FileReader();
      // fileReader.readAsText(file, "utf-8"); // or euc-kr
      // fileReader.onload = function () {
      //   console.log(fileReader.result);
      // };
    }
  };

  return (
    <Box sx={{ m: 2 }}>
      <input
        type="file"
        ref={fileInputRef}
        accept=".json,.md"
        style={{ display: "none" }}
        multiple
        onChange={(e) => fileUpload(e)}
      />

      <div style={{ height: 400 }}>
        <FullFileBrowser
          files={files}
          folderChain={folderChain}
          fileActions={fileActions}
          onFileAction={handleFileAction}
          thumbnailGenerator={thumbnailGenerator}
          // disableDefaultFileActions={true} // default false
          // doubleClickDelay={500} // ms
          // disableSelection={true} // default false 파일 선택이 해제됨
          // disableDragAndDrop={true} // 드래그 앤 드랍 기능 off
          // disableDragAndDropProvider={true} // default false, 다른 드래그 앤 드롭은 유지
          // defaultSortActionId={ChonkyActions.SortFilesByDate.id} // or SortFilesByName, SortFilesBySize
          // defaultFileViewActionId={ChonkyActions.EnableListView.id} // or EnableGridView
          // clearSelectionOnOutsideClick={false} // default true 브라우저 외부 클릭 시 파일 선택 해제
          darkMode={darkMode}
          {...props}
        />
      </div>
    </Box>
  );
});

export default ChonkyBrowser;

 

ChonkyLoader.js

import React, { useEffect, useState } from "react";
import ChonkyBrowser from "./ChonkyBrowser";

import * as gh from "./githublibrary.js";

const ChonkyLoader = () => {
  const [chonkyMap, setChonkyMap] = useState(undefined);

  const getChonkyMap = async () => {
    let result = await gh.fileRead("actions/config/chonky_map.json");
    if (result === undefined) return;

    let repoMap = JSON.parse(result);
    setChonkyMap(repoMap);
    console.log(repoMap);
  };

  useEffect(() => {
    getChonkyMap();
  }, []);

  return <div>{chonkyMap && <ChonkyBrowser chonkyMap={chonkyMap} />}</div>;
};

export default ChonkyLoader;

 

ToastUIEditor.js

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

import { useLocation } from "react-router-dom";

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

import * as gh from "./githublibrary.js";

// 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 html2pdf from 'html2pdf.js';

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 ToastEditor = () => {
  const location = useLocation();

  const editorRef = useRef(null);
  const [editMode, setEditMode] = useState(false);
  let initData = `# 제목

  ***~~<span style="color: #EE2323">내용</span>~~***
  
  * [x] 체크박스
  * [ ] 체크박스 2`;

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

  const init = async() => {
    let item = localStorage.getItem(CONTENT_KEY);
    let open = localStorage.getItem("FILE_PATH");
    
    // if(location.state) {
    if(open) {
      // let filePath = location.state.filePath;
      let filePath = JSON.parse(open).filePath;
      let result = await gh.fileRead(`actions/${filePath}`);

      if(result !== undefined) item = result;
      
      localStorage.removeItem("FILE_PATH");
    }

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

      if (item) viewer.setMarkdown(item);
      else viewer.setMarkdown(initData);
    }

    if (item) {
      if (editorRef.current) editorRef.current.getInstance().setMarkdown(item);
    } else {
      if (editorRef.current)
        editorRef.current.getInstance().setMarkdown(initData);
    }
  }

  useEffect(() => {
    init();
  }, [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 id="pdf-download" 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 ToastEditor;

 

githublibrary.js

import { Octokit } from "@octokit/rest";

const myKey = process.env.REACT_APP_MY_TOKEN;
const repo = `YOUR_ROPO`;

export const fileRead = async (path) => {
  try {
    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 decodeURIComponent(escape(window.atob(result.data.content)));
  } catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};
반응형

댓글