리액트, Node JS - Socket.IO로 Toast UI Editor 동시 편집하기
참고
- debounce vs throttle로 최적화하기 with useMemo (lodash-es)
socket.io를 이용해서 구글 docs 공동 작업처럼 리액트에서 문서를 동시에 편집해 보자.
구현
소켓 서버는 매우 간단하다.
클라이언트는 전체 Editor의 내용을 보내기 때문에 현재 data를 currentData 저장하도록 하였다.
그리고 broadcast로 다른 클라이언트에게 에디터에 있는 내용을 전송한다.
const { Server } = require("socket.io");
let currentData = undefined;
const io = new Server("3333", {
cors: {
origin: "http://localhost:3000",
},
});
io.sockets.on("connection", (socket) => {
socket.on("sendData", (data) => {
currentData = data;
socket.broadcast.emit("respondData", currentData);
});
socket.on("disconnect", () => {});
});
Toast UI Editor(클라이언트)는 이전 글에서 불필요한 내용은 삭제하고 아래 코드에서 시작한다. (저장하기 등 삭제)
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";
//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 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();
localStorage.setItem(CONTENT_KEY, markDownContent);
};
const init = () => {
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],
});
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>
{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 ToastEditor;
먼저 socket 클라이언트를 추가한다.
import { io } from "socket.io-client";
let socketIO = io("http://localhost:3333", { autoConnect: false });
viewer를 가져올 수 있도록 getViewerElement 메서드를 만든다.
const getViewerElement = () => {
try {
let viewer = new Viewer({
el: document.querySelector(".toast-editor-viewer"),
viewer: true,
height: "400px",
usageStatistics: false, // 통계 수집 거부
plugins: [tableMergedCell],
});
return viewer;
} catch (e) {
console.log(e);
return undefined;
}
}
그러면 init은 다음과 같이 변경된다.
const init = () => {
let item = localStorage.getItem(CONTENT_KEY);
if (editMode === false) {
const viewer = getViewerElement();
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);
}
};
socket 서버에서 respondData에 대한 callback 함수를 만들자.
viewer 상태인 경우는 viewer에서 setHTML을 이용해 내용을 변경하고,
editor 상태인 경우에는 instance를 가져와서 setHTML으로 내용을 변경한다.
const respondDataCallback = (data) => {
if(!data) return;
let viewer = getViewerElement();
if(viewer)
viewer.preview.setHTML(data);
else {
if (editorRef.current)
editorRef.current.getInstance().setHTML(data);
}
}
useEffect(() => {
socketIO.connect();
if (!socketIO) return;
socketIO.on("respondData", respondDataCallback);
return () => {
socketIO.off("respondData", respondDataCallback);
};
}, [])
그리고 클라이언트는 다른 클라이언트가 편집 중인 내용을 얻기 위해 sendData를 호출해 현재 데이터를 갱신한다.
once를 사용하면 소켓 연결 이후 딱 한 번만 메서드를 호출할 수 있다.
useEffect(() => {
socketIO.once("initData", (data) => {
let viewer = getViewerElement();
if (data) viewer.preview.setHTML(data.currentData);
});
}, []);
서버에는 socket.emit에 initData 메시지를 등록해서, 현재 수정 중인 data를 접속한 클라이언트에게 보낸다.
io.sockets.on("connection", (socket) => {
socket.emit("initData", {
data: currentData,
});
...
socket.on("disconnect", () => {});
});
이제 키보드 이벤트로 sendData 이벤트를 등록하면 된다.
로컬에 뷰어 / 편집 모드 전환에 대해 내용을 저장하기 위해 handleSave를 호출하였다.
const onKeyUp = (type, e) => {
handleSave();
socketIO.emit("sendData", e.target.innerHTML);
}
...
<Editor
ref={editorRef}
onKeyup={onKeyUp}
...
/>
현재 방식은 데이터를 통째로 서버에 보내기 때문에 데이터가 많을수록 딜레이가 발생할 수 있다.
실제 최적화를 위해서는 변경된 값을 서버에 보내고, 클라이언트도 변경된 값을 받아서 처리하는 것이 좋다.
클라이언트에서는 다른 클라이언트의 입력 이벤트를 매번 처리하지 않고, (동시에 입력이 많이 발생하지 않는다면)
한 번에 처리하도록 debounce를 사용해서, 매번 갱신하지 않고, 일정 시간 이후 한 번에 갱신할 수 있다.
import { debounce } from "lodash-es";
...
const debounceCallback = debounce((data) => {
let viewer = getViewerElement();
if (viewer) viewer.preview.setHTML(data);
else {
if (editorRef.current) editorRef.current.getInstance().setHTML(data);
}
}, 1000);
const respondDataCallback = (data) => {
if (!data) return;
debounceCallback(data);
};
다만 현재 구현은 데이터를 한 번에 보내는 방식이므로, "동시에" 편집 중인 경우 편집이 제대로 이루어지지 않는다.
전체 코드는 다음과 같다.
socketIOServer.js
const { Server } = require("socket.io");
let currentData = undefined;
const io = new Server("3333", {
cors: {
origin: "http://localhost:3000",
},
});
io.sockets.on("connection", (socket) => {
socket.emit("initData", {
currentData,
});
socket.on("sendData", (data) => {
console.log(data);
currentData = data;
socket.broadcast.emit("respondData", currentData);
});
socket.on("disconnect", () => {});
});
ToastEditor.js (Client)
import React, { useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";
import { debounce } from "lodash-es";
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";
//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";
let socketIO = io("http://localhost:3333", { autoConnect: false });
const ToastEditor = () => {
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();
localStorage.setItem(CONTENT_KEY, markDownContent);
};
const getViewerElement = () => {
try {
let viewer = new Viewer({
el: document.querySelector(".toast-editor-viewer"),
viewer: true,
height: "400px",
usageStatistics: false, // 통계 수집 거부
plugins: [tableMergedCell],
});
return viewer;
} catch (e) {
console.log(e);
return undefined;
}
};
const init = () => {
let item = localStorage.getItem(CONTENT_KEY);
if (editMode === false) {
const viewer = getViewerElement();
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]);
const debounceCallback = debounce((data) => {
let viewer = getViewerElement();
if (viewer) viewer.preview.setHTML(data);
else {
if (editorRef.current) editorRef.current.getInstance().setHTML(data);
}
}, 1000);
const respondDataCallback = (data) => {
if (!data) return;
debounceCallback(data);
};
useEffect(() => {
socketIO.connect();
if (!socketIO) return;
socketIO.on("respondData", respondDataCallback);
return () => {
socketIO.off("respondData", respondDataCallback);
};
}, []);
useEffect(() => {
socketIO.once("initData", (data) => {
let viewer = getViewerElement();
if (data) viewer.preview.setHTML(data.currentData);
});
}, []);
const onKeyUp = (type, e) => {
handleSave();
socketIO.emit("sendData", e.target.innerHTML);
};
return (
<div>
<Box sx={{ m: 2 }}>
<h1>Toast UI Editor</h1>
<Button
variant="outlined"
color="secondary"
sx={{ m: 1 }}
onClick={() => setEditMode(!editMode)}
>
{editMode ? "돌아가기" : "편집하기"}
</Button>
{editMode === false && <div className="toast-editor-viewer"></div>}
{editMode === true && (
<Editor
ref={editorRef}
// initialValue={initContents}
onKeyup={onKeyUp}
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;