참고
- 파일 브라우저 만들기
- 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 Editor의 init에서 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를 전달할 방법이 없다.
따라서 localStorage나 query string을 이용해야 한다.
여기서는 localStorage에 filePath를 저장하도록 수정하였다.
// 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;
}
};
'개발 > React' 카테고리의 다른 글
리액트 - SWR로 상태 관리하기 (Managing State with SWR) (0) | 2024.03.22 |
---|---|
리액트 - recoil로 상태 관리하기 (Managing State with recoil) (0) | 2024.03.22 |
리액트 - 깃허브 리포지토리를 파일 브라우저로 불러오기 (Chonky Browser with GitHub Repository) (0) | 2024.03.16 |
리액트 - 커스텀 액션 추가하기 (Add Custom Actions) (0) | 2024.03.16 |
리액트 - 액션 추가하고 다크 모드 구현하기 (Add ChonkyActions for Dark Mode) (0) | 2024.03.16 |
댓글