개발/React

리액트, Node JS - 다른 클라이언트가 선택한 셀 표시하기 (Highlighting Cells Selected by Another Client)

피로물든딸기 2024. 4. 13. 02:18
반응형

리액트 전체 링크

Node JS 전체 링크

 

참고

- https://reactgrid.com/features

- Socket.IO로 로그인 유저 관리하기

- module.css로 CSS 스타일 관리하기

 

스프레드시트 공동 작업하기 Project Settings
Handsontable에 Socket.IO 적용하기
변경된 셀만 데이터 전송해서 최적화하기
행 / 열 이동 및 크기 변경 연동하기
다른 클라이언트가 선택한 셀 표시하기

 

react-grid에는 공동 작업에 대한 셀 하이라이팅 기능을 제공한다.

 

아쉽게도 handsontable에는 해당 기능을 제공하지 않는다.

 

따라서 기존 기능을 이용해서 비슷하게 구현해 보자.


하이라이트 + 툴팁 만들기

 

먼저 임의의 셀의 테두리 색을 변경하고, tooltip이 나타나도록 해보자.

 

예시는 다음과 같다.

 

정보는 tableInfohighlight에 추가한다.

(1, 1) 셀빨갛게, 그리고 Lilly라는 툴팁이 나타나고,

(5, 3) 셀파랗게, 그리고 Joe라는 툴팁이 나타나게 한다.

  const [tableInfo, setTableInfo] = useState({
    data: defaultData,
    rowHeights: 25,
    colWidths: 60,
    highlights: [
      {
        row: 1,
        col: 1,
        loginID: "Lilly",
        borderColor: "red",
      },
      {
        row: 5,
        col: 3,
        loginID: "Joe",
        borderColor: "blue",
      },
    ],
  });

 

그리고 셀 하이라이트를 위한 css 파일(CustomTable.module.css)을 만든다.

.tooltip {
    z-index: 1010;
    position: fixed;
    background-color: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 5px;
    border-radius: 3px;
}

.other {    
    background-color: transparent;
    pointer-events: "none";
}

.box_red {
    box-shadow: inset 0 0 0 2px red;
}

.box_blue {
    box-shadow: inset 0 0 0 2px blue;
}

.box_green {
    box-shadow: inset 0 0 0 2px green;
}

.box_orange {
    box-shadow: inset 0 0 0 2px orange;
}

.box_purple {
    box-shadow: inset 0 0 0 2px purple;
}

 

module.css의 stylesimport 한다.

import styles from "./CustomTable.module.css";

 

makeTable에서 highlights 정보를 받는다.

그리고 mouse position(x, y)tooltip을 보여줄지 여부를 나타내기 위한 변수를 선언한다.

  const makeTable = () => {
    ...
    
    myTable = new Handsontable(container, {
      ...options,
      ...customOptions,
    });

    let highlights = tableInfo.highlights;
    let mouseX, mouseY, showTooltip = false;

    ...
  };

 

먼저 셀에 마우스를 가져갔을 때(afterOnCellMouseOver), 콜백을 추가한다.

highlightCell에 해당하는 셀이라면, tooltip을 위한 div 태그를 추가하고 .tooltipcss로 추가한다.

그리고 tooltip이 나오도록 위치를 설정하고 showTooltiptrue로 변경한다.

    // cell에 mouse를 over하는 경우 tooltip calssName를 추가
    myTable.addHook("afterOnCellMouseOver", (event, coords, td) => {
      let highlightCell = highlights.find((item) => item.row === coords.row && item.col === coords.col);
      if(highlightCell === undefined) return;

      let tooltip = document.createElement("div");
      if (tooltip) tooltip.remove(); // 이전 tooltip이 지워지지 않았다면 삭제
      
      tooltip.textContent = highlightCell.loginID;
      tooltip.classList.add(`${styles.tooltip}`);

      document.body.appendChild(tooltip); //<div> tooltip 추가

      // tooltip이 나오는 위치 조절
      let cellOffset = td.getBoundingClientRect();
      mouseX = cellOffset.left + window.scrollX + cellOffset.width / 2 + 10;
      mouseY = cellOffset.top + window.scrollY - 30;

      showTooltip = true;
    });

 

반대로 마우스가 셀 밖으로 나가는 경우 툴팁을 삭제하고 showTooltipfalse로 변경한다.

    // mouse가 cell을 벗어나면 tooltip calssName 삭제
    myTable.addHook("afterOnCellMouseOut", (event, coords, TD) => {
      let tooltip = document.querySelector(`.${styles.tooltip}`);
      if (tooltip)  tooltip.remove();
      showTooltip = false;
    });

 

이제 mouse가 셀 위에 있는 경우, tooltip이 보이도록 한다.

이때 styles.other이 포함된 경우만 tooltip이 보이게 된다.

    document.addEventListener("mouseover", (event) => {
      let targetElement = event.target;
      let check = targetElement.classList.contains(`${styles.custom}`);
      let tooltip = document.querySelector(`.${styles.tooltip}`);      
      
      if (check && tooltip) {
        tooltip.style.top = mouseY + 10 + "px";
        tooltip.style.left = mouseX + 10 + "px";
        tooltip.style.display = showTooltip ? "block" : "none";
      }
    });

 

전체 하이라이트된 셀에 대해서 setCellMeta를 설정하면 된다.

기존에 있던 className하이라이트와 관련된 css(other + box color)를 추가한다.

    for (let cell of highlights) {
      let { row, col, borderColor } = cell;
      let currentClassName = myTable.getCellMeta(row, col).className;
      myTable.setCellMeta(
        row,
        col,
        "className",
        `${currentClassName} ${styles.other} ${styles[`box_${borderColor}`]
        }`
      );
    }

    myTable.render();

Socket Server

 

이제 소켓을 적용해 보자.

편의상 중복 로그인은 하지 않는다고 가정한다.

 

로그인된 아이디를 저장할 Map과 색을 설정할 color / counter를 선언한다.

const loginInfo = new Map();
const color = ["red", "blue", "green", "orange", "purple"];
let counter = 0;

 

로그인된 아이디의 row, col 정보를 broadcast로 다른 클라이언트에게 전송하면 된다.

disconnect가 발생하는 경우(또는 로그아웃을 정의), loginInfo에서 해당 유저 정보를 지운다.

  socket.on("sendHighlight", (loginID, row, col) => {
    const clientId = socket.id;

    loginInfo.set(clientId, {
      row,
      col,
      loginID,
      borderColor: color[counter++ % 5],
      clientId,
    });

    // console.log(loginID, row, col, loginInfo);

    let highlightsInfo =  Array.from(loginInfo.values());
    socket.broadcast.emit("resHighlight", highlightsInfo);
  });

  socket.on("disconnect", () => {
    const clientId = socket.id;
    loginInfo.delete(clientId);    
  });

Socket Client

 

클릭을 할 때마다(afterSelection), 선택된 셀을 연동할 수도 있다.

    afterSelection: function(sr, sc, er, ec) {
      console.log(`Selected cells: ${sr},${sc} to ${er},${ec}`);  
      socketIO.emit("sendHighlight", location.state.loginID, sr, sc);
    },

 

하지만 handsontable은 공동 편집 기능을 지원하지 않아서,

table의 정보가 갱신되면 render() 호출에 의해 다른 클라이언트가 편집 중인 셀이 닫히게 된다.

 

따라서 셀이 편집될 때만(afterChange : edit) 갱신되도록 해서 rendering 최소화하자.

    afterChange: function (changes, source) {
      // changes : 변경된 데이터 정보, source : 변경을 발생시킨 원인
      if (source === "loadData") return;

      if(source === "edit") {
        let [row, col] = changes[0];
        console.log(row, col);
        socketIO.emit("sendHighlight", location.state.loginID, row, col);        
      }

      console.log("Changed Data :", source, changes);
      socketIO.emit("changeData", changes);
    },

 

highlight 콜백 함수는 다음과 같다.

자신의 하이라이트는 필요 없기 때문에 filter로 거른다.

  const resHighlight = (highlightsInfo) => {
    let highlights = highlightsInfo.filter((item) => item.clientId !== socketIO.id);
    
    console.log(highlights);    

    setTableInfo((prev) => {
      return {
        ...prev,
        highlights,
      };
    });
  }

 

그리고 initData에서는 highlights를 빈 배열로 받도록 한다.

  useEffect(() => {
    socketIO.once("initData", (data) => {
      console.log(data);
      if (data.currentData) {
        setTableInfo({
          data: data.currentData,
          rowHeights: data.currentRowHeights,
          colWidths: data.currentColWidths,
          highlights: [],
        });
      } else { 
        ...

전체 코드는 다음과 같다.

 

socketIOServer.js

const { Server } = require("socket.io");

let currentData = undefined;
let currentRowHeights = undefined;
let currentColWidths = undefined;

const io = new Server("3333", {
  cors: {
    origin: "http://localhost:3000",
  },
});

const loginInfo = new Map();
const color = ["red", "blue", "green", "orange", "purple"];
let counter = 0;

const insertRows = (table, rowIndex, amount) => {
  const afterIndex = table.slice(rowIndex);
  const emptyArray = Array.from({ length: table[0].length }, () => "");
  const newRows = Array.from({ length: amount }, () => emptyArray);
  const newArray = table.slice(0, rowIndex).concat(newRows, afterIndex);

  return newArray;
};

const deleteRows = (table, rowIndex, amount) => {
  const newTable = [...table];

  newTable.splice(rowIndex, amount);

  return newTable;
};

const insertColumns = (table, colIndex, amount) => {
  const newMatrix = [];

  for (let i = 0; i < table.length; i++) {
    const newRow = [...table[i]];

    for (let k = 0; k < amount; k++) {
      newRow.splice(colIndex + k, 0, "");
    }

    newMatrix.push(newRow);
  }

  return newMatrix;
};

const deleteColumns = (table, colIndex, amount) => {
  const newTable = [];
  for (let i = 0; i < table.length; i++) {
    const newRow = [...table[i]];
    newRow.splice(colIndex, amount);
    newTable.push(newRow);
  }

  return newTable;
};

const transpose = (array) => {
  const rows = array.length;
  const cols = array[0].length;
  const transposedArray = [];

  for (let j = 0; j < cols; j++) {
    transposedArray[j] = [];
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      transposedArray[c][r] = array[r][c];
    }
  }

  return transposedArray;
};

const moveRowTable = (table, movedRows, finalIndex) => {
  let keyTable = table.map((item, key) => {
    return {
      index: key,
      row: item,
    };
  });

  let movedTable = table.filter(
    (item, key) => movedRows.includes(key) === true
  );
  let movedKeyTable = movedTable.map((item) => {
    return {
      index: "new",
      row: item,
    };
  });

  let left = keyTable.slice(0, finalIndex);
  let right = keyTable.slice(finalIndex);
  let newArrayKey = [...left, ...movedKeyTable, ...right];

  newArrayKey = newArrayKey.filter(
    (item) => movedRows.includes(item.index) === false
  );
  let newArray = newArrayKey.map((item) => item.row);

  return newArray;
};

const moveColumnTable = (table, movedColumns, finalIndex) => {
  let transposedTable = transpose(table);
  let movedTable = moveRowTable(transposedTable, movedColumns, finalIndex);
  return transpose(movedTable);
};

io.sockets.on("connection", (socket) => {
  socket.emit("initData", {
    currentData,
    currentRowHeights,
    currentColWidths,
  });

  socket.on("setCurrentData", (data) => {
    console.log("setCurrentData");
    currentData = data;
  });

  socket.on("createRow", (index, amount, rowHeights) => {
    console.log("createRow", index, amount);
    currentData = insertRows(currentData, index, amount);
    currentRowHeights = rowHeights;
    socket.broadcast.emit("resCreateRow", index, amount, currentRowHeights);
  });

  socket.on("removeRow", (index, amount, rowHeights) => {
    console.log("removeRow", index, amount);
    currentData = deleteRows(currentData, index, amount);
    currentRowHeights = rowHeights;
    socket.broadcast.emit("resRemoveRow", index, amount, currentRowHeights);
  });

  socket.on("createCol", (index, amount, colWidths) => {
    console.log("createCol", index, amount);
    currentData = insertColumns(currentData, index, amount);
    currentColWidths = colWidths;
    socket.broadcast.emit("resCreateCol", index, amount, currentColWidths);
  });

  socket.on("removeCol", (index, amount, colWidths) => {
    console.log("removeCol", index, amount);
    currentData = deleteColumns(currentData, index, amount);
    currentColWidths = colWidths;
    socket.broadcast.emit("resRemoveCol", index, amount, currentColWidths);
  });

  socket.on("changeData", (changes) => {
    console.log("changeData", changes);
    for (let change of changes) {
      let [row, col, before, after] = change;
      currentData[row][col] = after;
    }
    socket.broadcast.emit(
      "resChangeData",
      changes,
      currentRowHeights,
      currentColWidths
    );
  });

  socket.on("moveRow", (movedRows, finalIndex, rowHeights) => {
    console.log("moveRow", movedRows, finalIndex);
    currentData = moveRowTable(currentData, movedRows, finalIndex);
    currentRowHeights = rowHeights;
    socket.broadcast.emit(
      "resMoveRow",
      movedRows,
      finalIndex,
      currentRowHeights
    );
  });

  socket.on("moveCol", (movedColumns, finalIndex, colWidths) => {
    console.log("moveCol", movedColumns, finalIndex);
    currentData = moveColumnTable(currentData, movedColumns, finalIndex);
    currentColWidths = colWidths;
    socket.broadcast.emit(
      "resMoveCol",
      movedColumns,
      finalIndex,
      currentColWidths
    );
  });

  socket.on("setRowHeights", (initRowHeights) => {
    console.log("setRowHeights", initRowHeights);
    currentRowHeights = initRowHeights;
  });

  socket.on("setColWidths", (initColWidths) => {
    console.log("setColWidths", initColWidths);
    currentColWidths = initColWidths;
  });

  socket.on("resizeRow", (rowHeights) => {
    console.log("resizeRow", rowHeights);
    currentRowHeights = rowHeights;
    socket.broadcast.emit("resResizeRow", rowHeights);
  });

  socket.on("resizeCol", (colWidths) => {
    console.log("resizeCol", colWidths);
    currentColWidths = colWidths;
    socket.broadcast.emit("resResizeCol", colWidths);
  });

  socket.on("changeMeta", (row, column, key, value) => {
    console.log("changeMeta", row, column, key, value);
    socket.broadcast.emit("resChangeMeta", row, column, key, value);
  });

  // socket.on("sendData", (data) => {
  //   console.log(data);
  //   currentData = data;
  //   socket.broadcast.emit("respondData", currentData);
  // });

  socket.on("sendHighlight", (loginID, row, col) => {
    const clientId = socket.id;

    loginInfo.set(clientId, {
      row,
      col,
      loginID,
      borderColor: color[counter++ % 5],
      clientId,
    });

    // console.log(loginID, row, col, loginInfo);

    let highlightsInfo =  Array.from(loginInfo.values());
    socket.broadcast.emit("resHighlight", highlightsInfo);
  });

  socket.on("disconnect", () => {
    const clientId = socket.id;
    loginInfo.delete(clientId);    
  });
});

 

SocketTable.js (Client)

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 { io } from "socket.io-client";

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],
];

let socketIO = io("http://localhost:3333", { autoConnect: false });

const getRowHeights = (handsOnTable) => {
  let countRows = handsOnTable.countRows();
  let rowHeights = [];
  for (let r = 0; r < countRows; r++)
    rowHeights.push(handsOnTable.getRowHeight(r));
  return rowHeights;
};

const getColWidths = (handsOnTable) => {
  let countCols = handsOnTable.countCols();
  let colWidths = [];
  for (let c = 0; c < countCols; c++)
    colWidths.push(handsOnTable.getColWidth(c));
  return colWidths;
};

const insertRows = (table, rowIndex, amount) => {
  const afterIndex = table.slice(rowIndex);
  const emptyArray = Array.from({ length: table[0].length }, () => "");
  const newRows = Array.from({ length: amount }, () => emptyArray);
  const newArray = table.slice(0, rowIndex).concat(newRows, afterIndex);

  return newArray;
};

const deleteRows = (table, rowIndex, amount) => {
  const newTable = [...table];

  newTable.splice(rowIndex, amount);

  return newTable;
};

const insertColumns = (table, colIndex, amount) => {
  const newMatrix = [];

  for (let i = 0; i < table.length; i++) {
    const newRow = [...table[i]];

    for (let k = 0; k < amount; k++) {
      newRow.splice(colIndex + k, 0, "");
    }

    newMatrix.push(newRow);
  }

  return newMatrix;
};

const deleteColumns = (table, colIndex, amount) => {
  const newTable = [];
  for (let i = 0; i < table.length; i++) {
    const newRow = [...table[i]];
    newRow.splice(colIndex, amount);
    newTable.push(newRow);
  }

  return newTable;
};

const transpose = (array) => {
  const rows = array.length;
  const cols = array[0].length;
  const transposedArray = [];

  for (let j = 0; j < cols; j++) {
    transposedArray[j] = [];
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      transposedArray[c][r] = array[r][c];
    }
  }

  return transposedArray;
};

const moveRowTable = (table, movedRows, finalIndex) => {
  let keyTable = table.map((item, key) => {
    return {
      index: key,
      row: item,
    };
  });

  let movedTable = table.filter(
    (item, key) => movedRows.includes(key) === true
  );
  let movedKeyTable = movedTable.map((item) => {
    return {
      index: "new",
      row: item,
    };
  });

  let left = keyTable.slice(0, finalIndex);
  let right = keyTable.slice(finalIndex);
  let newArrayKey = [...left, ...movedKeyTable, ...right];

  newArrayKey = newArrayKey.filter(
    (item) => movedRows.includes(item.index) === false
  );
  let newArray = newArrayKey.map((item) => item.row);

  return newArray;
};

const moveColumnTable = (table, movedColumns, finalIndex) => {
  let transposedTable = transpose(table);
  let movedTable = moveRowTable(transposedTable, movedColumns, finalIndex);
  return transpose(movedTable);
};

const SocketTable = () => {
  const location = useLocation();
  const [loginID, setLoginID] = useState("");
  const [tableInfo, setTableInfo] = useState({
    data: defaultData,
    rowHeights: 25,
    colWidths: 60,
    highlights: [],
  });

  const customOptions = {
    afterChange: function (changes, source) {
      // changes : 변경된 데이터 정보, source : 변경을 발생시킨 원인
      if (source === "loadData") return;

      if(source === "edit") {
        let [row, col] = changes[0];
        console.log(row, col);
        socketIO.emit("sendHighlight", location.state.loginID, row, col);        
      }

      console.log("Changed Data :", source, changes);
      socketIO.emit("changeData", changes);
    },
    afterCreateRow: function (index, amount) {
      console.log("create row :", index, amount);

      let rowHeights = getRowHeights(this);
      socketIO.emit("createRow", index, amount, rowHeights);
    },
    afterRemoveRow: function (index, amount) {
      console.log("remove row :", index, amount);

      let rowHeights = getRowHeights(this);
      socketIO.emit("removeRow", index, amount, rowHeights);
    },
    afterCreateCol: function (index, amount) {
      console.log("create col :", index, amount);

      let colWidths = getColWidths(this);
      socketIO.emit("createCol", index, amount, colWidths);
    },
    afterRemoveCol: function (index, amount) {
      console.log("remove col :", index, amount);

      let colWidths = getColWidths(this);
      socketIO.emit("removeCol", index, amount, colWidths);
    },
    afterRowMove: function (movedRows, finalIndex, dropIndex, movePossible, orderChanged) {
      console.log("move row", movedRows, finalIndex, dropIndex, movePossible, orderChanged);

      let rowHeights = getRowHeights(this);
      setTableInfo((prev) => {
        return {
          ...prev,
          data: this.getData(),
          rowHeights,
        };
      });

      socketIO.emit("moveRow", movedRows, finalIndex, rowHeights);
    },
    afterColumnMove: function (movedColumns, finalIndex, dropIndex, movePossible, orderChanged) {
      console.log("move col", movedColumns, finalIndex, dropIndex, movePossible, orderChanged);

      let colWidths = getColWidths(this);
      setTableInfo((prev) => {
        return {
          ...prev,
          data: this.getData(),
          colWidths,
        };
      });

      socketIO.emit("moveCol", movedColumns, finalIndex, colWidths);
    },
    afterRowResize: function (newSize, row, isDoubleClick) {
      console.log("resize row");

      let rowHeights = getRowHeights(this);
      socketIO.emit("resizeRow", rowHeights);
    },
    afterColumnResize: function (newSize, column, isDoubleClick) {
      console.log("resize col");

      let colWidths = getColWidths(this);
      socketIO.emit("resizeCol", colWidths);
    },
    // afterSelection: function(sr, sc, er, ec) {
    //   console.log(`Selected cells: ${sr},${sc} to ${er},${ec}`);  
    //   socketIO.emit("sendHighlight", location.state.loginID, sr, sc);
    // },
  };

  const init = () => {
    setLoginID(location.state.loginID);
  };

  useEffect(init, []);

  // const respondDataCallback = (newData) => {
  //   setTableData(newData);
  // };

  const resCreateRow = (index, amount, currentRowHeights) => {
    console.log("createRow", index, amount, currentRowHeights);

    setTableInfo((prev) => {
      let newTable = insertRows(prev.data, index, amount);
      return {
        ...prev,
        data: newTable,
        rowHeights: currentRowHeights,
      };
    });
  };

  const resRemoveRow = (index, amount, currentRowHeights) => {
    console.log("removeRow", index, amount, currentRowHeights);

    setTableInfo((prev) => {
      let newTable = deleteRows(prev.data, index, amount);
      return {
        ...prev,
        data: newTable,
        rowHeights: currentRowHeights,
      };
    });
  };

  const resCreateCol = (index, amount, currentColWidths) => {
    console.log("createCol", index, amount, currentColWidths);

    setTableInfo((prev) => {
      let newTable = insertColumns(prev.data, index, amount);
      return {
        ...prev,
        data: newTable,
        colWidths: currentColWidths,
      };
    });
  };

  const resRemoveCol = (index, amount, currentColWidths) => {
    console.log("removeCol", index, amount, currentColWidths);

    setTableInfo((prev) => {
      let newTable = deleteColumns(prev.data, index, amount);
      return {
        ...prev,
        data: newTable,
        colWidths: currentColWidths,
      };
    });
  };

  const resChangeData = (changes, currentRowHeights, currentColWidths) => {
    console.log("changeData", changes, currentRowHeights, currentColWidths);

    setTableInfo((prev) => {
      let newTable = [...prev.data];
      for (let change of changes) {
        let [row, col, before, after] = change;
        newTable[row][col] = after;
      }

      return {
        ...prev,
        data: newTable,
        rowHeights: currentRowHeights,
        colWidths: currentColWidths,
      };
    });
  };

  const resMoveRow = (movedRows, finalIndex, currentRowHeights) => {
    console.log("resMoveRow", movedRows, finalIndex, currentRowHeights);

    setTableInfo((prev) => {
      let newTable = moveRowTable(prev.data, movedRows, finalIndex);
      return {
        ...prev,
        data: newTable,
        rowHeights: currentRowHeights,
      };
    });
  };

  const resMoveCol = (movedColumns, finalIndex, currentColWidths) => {
    console.log("resMoveCol", movedColumns, finalIndex, currentColWidths);

    setTableInfo((prev) => {
      let newTable = moveColumnTable(prev.data, movedColumns, finalIndex);
      return {
        ...prev,
        data: newTable,
        colWidths: currentColWidths,
      };
    });
  };

  const resResizeRow = (rowHeights) => {
    console.log("resResizeRow", rowHeights);
    setTableInfo((prev) => {
      return {
        ...prev,
        rowHeights,
      };
    });
  };

  const resResizeCol = (colWidths) => {
    console.log("resResizeCol", colWidths);
    setTableInfo((prev) => {
      return {
        ...prev,
        colWidths,
      };
    });
  };

  const resHighlight = (highlightsInfo) => {
    let highlights = highlightsInfo.filter((item) => item.clientId !== socketIO.id);
    
    console.log(highlights);    

    setTableInfo((prev) => {
      return {
        ...prev,
        highlights,
      };
    });
  }

  useEffect(() => {
    socketIO.connect();
    if (!socketIO) return;

    // socketIO.on("respondData", respondDataCallback);
    socketIO.on("resCreateRow", resCreateRow);
    socketIO.on("resRemoveRow", resRemoveRow);
    socketIO.on("resCreateCol", resCreateCol);
    socketIO.on("resRemoveCol", resRemoveCol);
    socketIO.on("resChangeData", resChangeData);
    socketIO.on("resMoveRow", resMoveRow);
    socketIO.on("resMoveCol", resMoveCol);
    socketIO.on("resResizeRow", resResizeRow);
    socketIO.on("resResizeCol", resResizeCol);
    socketIO.on("resHighlight", resHighlight);      

    return () => {
      // socketIO.off("respondData", respondDataCallback);
      socketIO.off("resCreateRow", resCreateRow);
      socketIO.off("resRemoveRow", resRemoveRow);
      socketIO.off("resCreateCol", resCreateCol);
      socketIO.off("resRemoveCol", resRemoveCol);
      socketIO.off("resChangeData", resChangeData);
      socketIO.off("resMoveRow", resMoveRow);
      socketIO.off("resMoveCol", resMoveCol);
      socketIO.off("resResizeRow", resResizeRow);
      socketIO.off("resResizeCol", resResizeCol);
      socketIO.off("resHighlight", resHighlight);      
    };
  }, []);

  useEffect(() => {
    socketIO.once("initData", (data) => {
      console.log(data);
      if (data.currentData) {
        setTableInfo({
          data: data.currentData,
          rowHeights: data.currentRowHeights,
          colWidths: data.currentColWidths,
          highlights: [],
        });
      } else {
        let basicRowHeight = 25;
        let basicColWidth = 60;
        let initRowHeights = new Array(defaultData.length).fill(basicRowHeight);
        let initColWidths = new Array(defaultData[0].length).fill(basicColWidth);

        socketIO.emit("setCurrentData", defaultData);
        socketIO.emit("setRowHeights", initRowHeights);
        socketIO.emit("setColWidths", initColWidths);
      }
    });
  }, []);

  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 tableInfo={tableInfo} customOptions={customOptions} />
    </div>
  );
};

export default SocketTable;

 

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";
import styles from "./CustomTable.module.css";

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 = ({
  tableInfo,
  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: tableInfo.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: tableInfo.colWidths /* 특정 위치 너비 변경 : [60, 120, 60, 60, 60, 60, 60] */,
    rowHeights: tableInfo.rowHeights,
    //mergeCells: [],
    licenseKey: "non-commercial-and-evaluation",
  };

  let myTable;
  const makeTable = () => {
    const container = document.getElementById("hot-app");
    container.innerHTML = "";

    console.log(tableInfo);

    //console.log(options);
    myTable = new Handsontable(container, {
      ...options,
      ...customOptions,
    });

    let highlights = tableInfo.highlights;
    let mouseX, mouseY, showTooltip = false;

    // cell에 mouse를 over하는 경우 tooltip calssName를 추가
    myTable.addHook("afterOnCellMouseOver", (event, coords, td) => {
      let highlightCell = highlights.find((item) => item.row === coords.row && item.col === coords.col);
      if(highlightCell === undefined) return;

      let tooltip = document.createElement("div");
      if (tooltip) tooltip.remove(); // 이전 tooltip이 지워지지 않았다면 삭제
      
      tooltip.textContent = highlightCell.loginID;
      tooltip.classList.add(`${styles.tooltip}`);

      document.body.appendChild(tooltip); //<div> tooltip 추가

      // tooltip이 나오는 위치 조절
      let cellOffset = td.getBoundingClientRect();
      mouseX = cellOffset.left + window.scrollX + cellOffset.width / 2 + 10;
      mouseY = cellOffset.top + window.scrollY - 30;

      showTooltip = true;
    });

    // mouse가 cell을 벗어나면 tooltip calssName 삭제
    myTable.addHook("afterOnCellMouseOut", (event, coords, TD) => {
      let tooltip = document.querySelector(`.${styles.tooltip}`);
      if (tooltip)  tooltip.remove();
      showTooltip = false;
    });

    document.addEventListener("mouseover", (event) => {
      let targetElement = event.target;
      let check = targetElement.classList.contains(`${styles.other}`);
      let tooltip = document.querySelector(`.${styles.tooltip}`);      
      
      if (check && tooltip) {
        tooltip.style.top = mouseY + 10 + "px";
        tooltip.style.left = mouseX + 10 + "px";
        tooltip.style.display = showTooltip ? "block" : "none";
      }
    });

    for (let cell of highlights) {
      let { row, col, borderColor } = cell;
      let currentClassName = myTable.getCellMeta(row, col).className;
      myTable.setCellMeta(
        row,
        col,
        "className",
        `${currentClassName} ${styles.other} ${styles[`box_${borderColor}`]
        }`
      );
    }

    myTable.render();
    if (setTable) setTable(myTable);
  };

  useEffect(() => {
    makeTable();
  }, [tableInfo]);

  return (
    <div>
      <Box sx={{ m: 2 }}>
        <DisplayCellStyle>
          <span>{displayCellInfo}</span>
        </DisplayCellStyle>
        <div id="hot-app" style={{ marginTop: "13px" }}></div>
      </Box>
    </div>
  );
};

export default SimpleHandsOnTable;
반응형