참고
- Hansontable Customizing with GitHub
- https://forum.handsontable.com/t/is-there-a-feature-for-simultaneous-editing/7510
- 스프레드시트 공동 작업하기 Project Settings
- Handsontable에 Socket.IO 적용하기
- 변경된 셀만 데이터 전송해서 최적화하기
- 행 / 열 이동 및 크기 변경 연동하기
- 다른 클라이언트가 선택한 셀 표시하기
다음과 같이 동시에 편집할 수 있는 스프레드 시트를 만들어보자.
그리고 다른 클라이언트가 작업하고 있는 셀의 정보도 알 수 있도록 하자.
주의 : handsontable은 공동 작업 기능을 제공하지 않는다. (링크 참고)
따라서 socket을 이용하여 다른 클라이언트의 셀을 변경하면 해당 클라이언트의 셀 편집기가 자동으로 닫히게 된다.
로그인 UI 구현
먼저 로그인 UI를 만들어보자.
로그인 + 채팅방 UI 만들기에서 navigate 되는 URL만 변경하였다.
import React from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import { useNavigate } from "react-router-dom";
const ChatLogin = () => {
const navigate = useNavigate();
const [id, setID] = React.useState("");
const handleChange = (event) => {
setID(event.target.value);
};
const login = () => {
if (id === "") return;
console.log(id);
navigate("/socket-table", { state: { loginID: id } });
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<FormControl sx={{ marginRight: 1, width: 300 }}>
<InputLabel>ID</InputLabel>
<Select
labelId="demo-simple-select-label"
value={id}
label="ID"
onChange={handleChange}
>
<MenuItem value="Lilly">Lilly</MenuItem>
<MenuItem value="Joe">Joe</MenuItem>
<MenuItem value="Emily">Emily</MenuItem>
<MenuItem value="Akane">Akane</MenuItem>
<MenuItem value="Eliot">Eliot</MenuItem>
<MenuItem value="Zoe">Zoe</MenuItem>
</Select>
</FormControl>
<Button variant="outlined" onClick={login}>
Login
</Button>
</Box>
</div>
);
};
export default ChatLogin;
/socket-table로 이동하기 때문에 리액트 라우터를 설정한다.
<Route path="/socket-table" element={<SocketTable />} />
/socket-table은 아래 컴포넌트로 이동하게 된다. (SocketTable.js)
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import { Avatar, Conversation, MessageSeparator } from "@chatscope/chat-ui-kit-react";
const AVATAR_MAP = {
Lilly: "https://chatscope.io/storybook/react/assets/lilly-aj6lnGPk.svg",
Joe: "https://chatscope.io/storybook/react/assets/joe-v8Vy3KOS.svg",
Emily: "https://chatscope.io/storybook/react/assets/emily-xzL8sDL2.svg",
Akane: "https://chatscope.io/storybook/react/assets/akane-MXhWvx63.svg",
Eliot: "https://chatscope.io/storybook/react/assets/eliot-JNkqSAth.svg",
Zoe: "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg",
};
const SocketTable = () => {
const location = useLocation();
const [loginID, setLoginID] = useState("");
const init = () => {
setLoginID(location.state.loginID);
};
useEffect(init, []);
return (
<div>
<Conversation
info="I'm fine, thank you, and you?"
lastSenderName={loginID}
name={loginID}
>
<Avatar name={loginID} src={AVATAR_MAP[loginID]} status="available" />
</Conversation>
<MessageSeparator style={{ marginTop: 5, marginBottom: 5 }} />
</div>
);
};
export default SocketTable;
실행 화면은 다음과 같다.
Handsontable 추가하기
Hansontable Customizing with GitHub의 최종 코드에서 현재 작업에 대해 불필요한 부분을 제거하였다.
SimpleHandsOnTable.js
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState } from "react";
import Handsontable from "handsontable";
import "handsontable/dist/handsontable.full.min.css";
import Box from "@mui/material/Box";
import styled from "styled-components";
const DisplayCellStyle = styled.div`
span {
background-color: grey;
position: relative;
padding: 0.4rem 0.85rem;
border: 1px solid transparent;
border-radius: 0.35rem;
}
`;
const SimpleHandsOnTable = ({ data, setTable, customOptions }) => {
const [displayCellInfo, setDisplaySetInfo] = useState("");
const [selectedCell, setSelectedCell] = useState([0, 0]);
const cellSelected = () => {
let selectedLast = myTable.getSelectedLast();
if (selectedLast[0] < 0 || selectedLast[1] < 0) return;
let value = myTable.getValue() || "";
setDisplaySetInfo(value);
setSelectedCell([selectedLast[0], selectedLast[1]]);
};
const options = {
data,
colHeaders: true,
rowHeaders: true,
wordWrap: false /* 줄 바꿈 off */,
manualColumnResize: true,
manualRowResize: true,
manualColumnMove: true,
manualRowMove: true,
allowInsertColumn: true,
allowInsertRow: true,
allowRemoveColumn: true,
allowRemoveRow: true,
autoWrapCol: true /* 마지막 셀 아래에서 다음 셀 위로 이동 */,
autoWrapRow: true /* 마지막 셀 옆에서 다음 셀 처음으로 이동 */,
dragToScroll: true /* 표를 클릭 후 드래그를 할 때, 같이 스크롤 되는지 여부 */,
persistentState: false /* 열 정렬 상태, 열 위치 및 열 크기를 로컬 스토리지에 저장 */,
outsideClickDeselects: false /* 셀 외부 클릭 시, 셀 선택 해제 */,
readOnly: false /* true : 모든 셀을 readOnly로 설정*/,
enterBeginsEditing: true /* true : 엔터 클릭 시 편집 모드, false : 다음 셀로 이동 */,
copyable: true /* 복사 가능 여부 */,
copyPaste: true /* 복사, 붙여넣기 가능 여부 */,
undo: true /* false : ctrl + z 비활성화 */,
trimWhitespace: false /* 자동 trim() 실행 후 셀에 저장 */,
contextMenu: true /* 마우스 왼쪽 버튼 클릭 시 컨텍스트 메뉴 */,
comments: true /* 주석, 메모 기능 context menu에 추가 */,
manualColumnFreeze: true /* freezeColumn context menu에 추가 */,
className: "htMiddle htCenter" /* Cell Alignment */,
width: 1000,
height: 1000,
startCols: 5 /* data가 없는 경우 기본 설정 */,
startRows: 3 /* data가 없는 경우 기본 설정 */,
afterSelection: cellSelected,
colWidths: 60 /* 특정 위치 너비 변경 : [60, 120, 60, 60, 60, 60, 60] */,
rowHeights: 25,
licenseKey: "non-commercial-and-evaluation",
};
let myTable;
const makeTable = () => {
const container = document.getElementById("hot-app");
container.innerHTML = "";
console.log(options);
myTable = new Handsontable(container, {
...options,
...customOptions,
});
myTable.render();
if(setTable) setTable(myTable);
};
useEffect(() => {
makeTable();
}, [data]);
return (
<div>
<Box sx={{ m: 2 }}>
<DisplayCellStyle>
<span>{displayCellInfo}</span>
</DisplayCellStyle>
<div id="hot-app" style={{ marginTop: "13px" }}></div>
</Box>
</div>
);
};
export default SimpleHandsOnTable;
SimpleHandsOnTable은 props로 data, setTable, customOptions를 받는다.
- data : 2D table
- setTable : 부모 컴포넌트에서 table에 접근하기 위한 useState의 setter
- customOptions : 부모 컴포넌트에서 기본 옵션을 변경하기 위한 정보
const SimpleHandsOnTable = ({ data, setTable, customOptions }) => {
이제 SocketTable에 SimpleHandsOnTable 컴포넌트를 추가한다.
SocketTable.js
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import { Avatar, Conversation, MessageSeparator } from "@chatscope/chat-ui-kit-react";
import SimpleHandsOnTable from "./SimpleHandsOnTable";
const AVATAR_MAP = {
Lilly: "https://chatscope.io/storybook/react/assets/lilly-aj6lnGPk.svg",
Joe: "https://chatscope.io/storybook/react/assets/joe-v8Vy3KOS.svg",
Emily: "https://chatscope.io/storybook/react/assets/emily-xzL8sDL2.svg",
Akane: "https://chatscope.io/storybook/react/assets/akane-MXhWvx63.svg",
Eliot: "https://chatscope.io/storybook/react/assets/eliot-JNkqSAth.svg",
Zoe: "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg",
};
const defaultData = [
["", "Tesla", "Nissan", "Toyota", "Honda", "Mazda", "Ford"],
["2017", 10, 11, 12, 13, 15, 16],
["2018", 10, 11, 12, 13, 15, 16],
["2019", 10, 11, 12, 13, 15, 16],
["2020", 10, 11, 12, 13, 15, 16],
["2021", 10, 11, 12, 13, 15, 16],
];
const customOptions = {};
const SocketTable = () => {
const location = useLocation();
const [loginID, setLoginID] = useState("");
const init = () => {
setLoginID(location.state.loginID);
};
useEffect(init, []);
return (
<div>
<Conversation
info="I'm fine, thank you, and you?"
lastSenderName={loginID}
name={loginID}
>
<Avatar name={loginID} src={AVATAR_MAP[loginID]} status="available" />
</Conversation>
<MessageSeparator style={{ marginTop: 5, marginBottom: 5 }} />
<SimpleHandsOnTable data={defaultData} customOptions={customOptions} />
</div>
);
};
export default SocketTable;
댓글