본문 바로가기
개발/React

React Handsontable로 csv 편집기 만들기 (15)

by 피로물든딸기 2021. 6. 13.
반응형

프로젝트 전체 링크

 

이전 - (14) handsontable methods

현재 - (15) callback function으로 선택된 Cell 보여주기 / 수정하기

다음 - (16) colWidths 옵션 / csv 파일 파싱 보완

 

깃허브에서 코드 확인하기


handsontable option으로 colWidths와 wordWrap = false을 추가해보자.

myTable = new Handsontable(container, {
  data: lib.makeTable(csvFile, 2, 3),
  colHeaders: true,         /* column header는 보이게 설정 */
  rowHeaders: true,         /* row header 보이게 설정 */
  
  colWidths: [60, 60, 60, 60, 60, 60, 60],
  wordWrap: false,          /* 줄 바꿈 x */
  
  width: "50%",
  ...
});

 

colWidths를 강제로 정하였으므로, column의 width가 고정된다.

왼쪽의 경우 wordWrap option이 없는 경우(default : true)이고 오른쪽은 false를 준 경우다.

wordWrap true vs wordWrap false

 

문자열의 길이가 일정치 않다면, 왼쪽의 경우가 조금 더 산만할 수 있다.

따라서 격자의 길이가 일정한 것을 선호한다면 wordWrap false로 오른쪽이 되도록 하면 된다.

 

문제는 문자열의 길이가 길 때, cell을 클릭해도 모든 문자열을 보기 힘들다는 것이다.

실제 엑셀에서는 셀을 클릭하면 수식 입력줄에 모든 문자열이 보인다.

 

handsontable의 method와 callback function을 이용하여 이름 상자 + 수식 입력줄을 구현해보자.


위의 내용을 구현하기 위해 hansdontable의 아래 메서드가 필요하다.

 

getValue() - 현재 선택된 Cell의 값을 return 한다.

getSelectedLast() - 현재 선택된 Cell의 범위를 return 한다.

                    (return [row start, column start, row end, column end])

setDataAtCell(row, col, value) - table의 row, column의 값을 value로 변경한다.

 

그리고 callback function : afterSelection이 필요하다.

afterSelection은 table을 선택할 때마다 발생하는 이벤트이다.

(callback function의 종류는 링크를 참고하자.)


구현 내용은 MyTable을 아래와 같이 수정하면 된다.

//MyTable.js
import React, { useEffect, useState } from "react";
import * as lib from "./library.js";

import "handsontable/dist/handsontable.full.css";
import Handsontable from "handsontable";

let myTable;
let currentRow, currentColumn;

const csvDownLoad = () => { ... };

const MyTable = ({ csvFile }) => {
  const [displayIndex, setDisplayIndex] = useState("");
  const [displayCell, setDisplayCell] = useState("");
  
  const selectCell = () => {
    let selected = myTable.getSelectedLast();

    currentRow = selected[0];
    currentColumn = selected[1];

    if(currentRow < 0 || currentColumn < 0) return;

    setDisplayCell(myTable.getValue());
    setDisplayIndex(`${lib.rowToAlpha(currentColumn + 1)}${currentRow + 1}`);
  }

  const setValueCell = (e) => {
    if(currentRow < 0 || currentColumn < 0) return;

    setDisplayCell(e.target.value); 
    myTable.setDataAtCell(currentRow, currentColumn, e.target.value);
  }

  const init = (csvFile) => {
    if (csvFile === undefined || csvFile.HEIGHT === 0) return;
  
    const container = document.getElementById("hot-app");
  
    myTable = new Handsontable(container, {
      data: lib.makeTable(csvFile, 2, 3),
      colHeaders: true,         /* column header는 보이게 설정 */
      rowHeaders: true,         /* row header 보이게 설정 */
      colWidths: [60, 60, 60, 60, 60, 60, 60],
      wordWrap: false,          /* 줄 바꿈 x */
      width: "50%",
      manualColumnResize: true, /* column 사이즈 조절 */
      manualRowResize: true,    /* row 사이즈 조절 */
      manualColumnMove: true,   /* column move 허용 */
      manualRowMove: true,      /* row move 허용 */
      dropdownMenu: true,       /* dropdown 메뉴 설정 */
      filters: true,            /* 필터 기능 on */
      contextMenu: true,        /* cell 클릭 시 메뉴 설정 */
      licenseKey: "non-commercial-and-evaluation",
      afterSelection: selectCell,
    });
  };
  
  useEffect(() => {
    init(csvFile);
  }, [csvFile]);

  return (
    <div>
      <button onClick={csvDownLoad}>DOWNLOAD</button>
      <div>
        <span>{displayIndex}</span>
        <input value={displayCell} onChange={setValueCell} />
      </div>
      <div id="hot-app">
      </div>
    </div>
  );
};

export default MyTable;

 

currentRow와 currentColumn을 선언한다.

let myTable;
let currentRow, currentColumn;

 

그리고 useState를 추가한다.

displayIndex는 이름 상자의 역할을 하고, displayCell은 수식 입력줄의 역할을 한다.

또한 useState로 선언된 display 변수를 사용하기 위해 init 함수를 MyTable 안으로 옮겼다. 

(이전까지 const MyTable = ({ csvFile }) => { ... } 외부에 있었다.)

const [displayIndex, setDisplayIndex] = useState("");
const [displayCell, setDisplayCell] = useState("");

 

display 변수를 보기 위해 csvDownload 버튼 아래에 span과 input을 추가한다. 

input에는 onChange 이벤트에 setValueCell(나중에 설명)을 연결한다. 

<div>
  <span>{displayIndex}</span>
  <input value={displayCell} onChange={setValueCell} />
</div>

 

myTable에는 afterSelectionselectCell을 추가하였다.

이제 cell을 Select, 선택하면 selectCell 함수가 호출된다.

myTable = new Handsontable(container, {
  data: lib.makeTable(csvFile, 2, 3),
  ...
  wordWrap: false,          /* 줄 바꿈 x */
  ...
  licenseKey: "non-commercial-and-evaluation",
  afterSelection: selectCell,
});

 

셀이 선택되면, 이름 상자에 현재 row/column이 보이고, 수식 입력줄에는 셀의 값이 보여야한다.

 

getValue를 이용해 선택된 셀의 값을 setDisplayCell에 넘겨주면 displayCell이 변한다.

getSelectedLast로 선택된 셀의 row/column을 알 수 있으므로 setDisplayIndex로 displayIndex를 변경한다.

row, col이 음수일 때는 아무 동작도 하지 않도록 방어 코드를 추가한다.

const selectCell = () => {
  let selected = myTable.getSelectedLast();

  currentRow = selected[0];
  currentColumn = selected[1];

  if(currentRow < 0 || currentColumn < 0) return;

  setDisplayCell(myTable.getValue());
  setDisplayIndex(`${lib.rowToAlpha(currentColumn + 1)}${currentRow + 1}`);
}

 

row와 column은 0부터 시작하지만, table은 1부터 시작하므로 1을 더해준다.

그리고 row는 알파벳으로 보여야하므로, rowToAlpha 함수로 row에 대한 alphabet을 변환한다.

해당 함수는 library에 추가하였다.

// library.js

export const rowToAlpha = (row) => {
  const numToAlpha = [
    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
    "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
    "U", "V", "W", "X", "Y", "Z",
  ];

  if (row <= 26) return numToAlpha[row - 1];

  if (26 < row && row <= 26 * 26) {
    let front = numToAlpha[parseInt(row / 26) - 1];
    let back = numToAlpha[(row % 26) - 1];

    if (row % 26 === 0) {
      back = "Z";
      front = numToAlpha[parseInt(row / 26) - 2];
    }

    return front + back;
  }

  return "too_long";
}

row에 대한 알파벳의 규칙이 까다로워서 A ~ ZZ 까지만 정상적으로 나올 수 있도록 만들었다.

 

이제 변수가 길어도 셀을 선택하면 display에서 문자열을 모두 확인할 수 있다.


setValueCell은 반대로 input에서 값을 입력하면 table 값이 변경되도록 한다.

 

위에서 input에 onChange 이벤트에 setValueCell을 연결해두었다.

input값이 변경되면 displayCell도 변경되고, 저장해둔 currentRow/Column으로 table의 값도 수정할 수 있다.

마찬가지로 row, col이 음수일 때는 아무 동작도 하지 않도록 방어 코드를 추가한다.

const setValueCell = (e) => {
  if(currentRow < 0 || currentColumn < 0) return;

  setDisplayCell(e.target.value); 
  myTable.setDataAtCell(currentRow, currentColumn, e.target.value);
}

 

이제 수식 입력줄에서 cell을 변경하면 table도 같이 변경되는 것을 볼 수 있다.

 

 

최종 코드는 아래와 같다.

//MyTable.js
import React, { useEffect, useState } from "react";
import * as lib from "./library.js";

import "handsontable/dist/handsontable.full.css";
import Handsontable from "handsontable";

let myTable;
let currentRow, currentColumn;

const getCell = (cell) => {
  if(cell === null) return ``;
  return cell.includes(",") ? `"${cell}"` : `${cell}`;
}

const csvDownLoad = () => {
  let rows = myTable.countRows();
  let cols = myTable.countCols();
  let tmpTables = myTable.getData(0, 0, rows - 1, cols - 1);
  let maxRow, maxCol;
  
  maxCol = 0;
  for(let r = 0; r < rows; r++) {
    for(let c = cols - 1; c >=0; c--) {
      if(!(tmpTables[r][c] === "" || tmpTables[r][c] === null)) {
        maxCol = (maxCol < c) ? c : maxCol;
        break;
      }
    }
  }

  maxRow = 0;
  for(let c = 0; c < cols; c++) {
    for(let r = rows - 1; r >=0; r--) {
      if(!(tmpTables[r][c] === "" || tmpTables[r][c] === null)) {
        maxRow = (maxRow < r) ? r : maxRow;
        break;
      }
    }
  }

  let parsing = myTable.getData(0, 0, maxRow, maxCol)
                .map((item) => item.map((cell) => getCell(cell)));
  let realTable = parsing.map((item) => item.join(",")).join("\n");

  lib.downLoadCsv(realTable);

  return;
};

const MyTable = ({ csvFile }) => {
  const [displayIndex, setDisplayIndex] = useState("");
  const [displayCell, setDisplayCell] = useState("");
  
  const selectCell = () => {
    let selected = myTable.getSelectedLast();

    currentRow = selected[0];
    currentColumn = selected[1];

    if(currentRow < 0 || currentColumn < 0) return;

    setDisplayCell(myTable.getValue());
    setDisplayIndex(`${lib.rowToAlpha(currentColumn + 1)}${currentRow + 1}`);
  }

  const setValueCell = (e) => {
    if(currentRow < 0 || currentColumn < 0) return;

    setDisplayCell(e.target.value); 
    myTable.setDataAtCell(currentRow, currentColumn, e.target.value);
  }

  const init = (csvFile) => {
    if (csvFile === undefined || csvFile.HEIGHT === 0) return;
  
    const container = document.getElementById("hot-app");
  
    myTable = new Handsontable(container, {
      data: lib.makeTable(csvFile, 2, 3),
      colHeaders: true,         /* column header는 보이게 설정 */
      rowHeaders: true,         /* row header 보이게 설정 */
      colWidths: [60, 60, 60, 60, 60, 60, 60],
      wordWrap: false,          /* 줄 바꿈 x */
      width: "50%",
      manualColumnResize: true, /* column 사이즈 조절 */
      manualRowResize: true,    /* row 사이즈 조절 */
      manualColumnMove: true,   /* column move 허용 */
      manualRowMove: true,      /* row move 허용 */
      dropdownMenu: true,       /* dropdown 메뉴 설정 */
      filters: true,            /* 필터 기능 on */
      contextMenu: true,        /* cell 클릭 시 메뉴 설정 */
      licenseKey: "non-commercial-and-evaluation",
      afterSelection: selectCell,
    });
  };
  
  useEffect(() => {
    init(csvFile);
  }, [csvFile]);

  return (
    <div>
      <button onClick={csvDownLoad}>DOWNLOAD</button>
      <div>
        <span>{displayIndex}</span>
        <input value={displayCell} onChange={setValueCell} />
      </div>
      <div id="hot-app">
      </div>
    </div>
  );
};

export default MyTable;

 

// library.js

const DELIMITER = ',';
const APOSTROPHE = '"';

export const handleOnDragLeave = (e, setState) => {
  e.preventDefault();
  setState(false);
  return false;
};

export const handleDragOver = (e, setState) => {
  e.preventDefault();
  setState(true);
  return false;
};

export const handleOnDrop = (e, setState, setCsvObject) => {
  e.preventDefault();

  let file = e.dataTransfer.files[0];
  let fileReader = new FileReader();

  fileReader.readAsText(file, "utf-8"); // or euc-kr

  fileReader.onload = function () {
    //console.log(fileReader.result); 
    parsingCsv(fileReader.result, setCsvObject);
    return;
  };

  setState(false);
  return false; 
};

export const handleUpload = (e, setCsvObject) => {
  let file = e.target.files[0];
  let fileReader = new FileReader();
  
  if(file === undefined) return; /* 방어 코드 추가 */

  fileReader.readAsText(file, "utf-8"); // or euc-kr

  fileReader.onload = function () {
    //console.log(fileReader.result); 
    parsingCsv(fileReader.result, setCsvObject);
  };
}

export const mySplit = (line, delimiter, ignore) => {
  let spt = [];
  let tmp = "";
  let flag = false;

  for(let i = 0; i < line.length; i++)
  {
    if(ignore === line[i] && flag === true) 
    {
      flag = false;
      continue;
    }
    else if(ignore === line[i])
    {
      flag = true;
      continue;
    } 
    
    if(line[i] === delimiter && flag === false) {
      spt.push(tmp);
      tmp = "";

      continue;
    }

    tmp += line[i];
  }

  spt.push(tmp);

  return spt;
}

export const parsingCsv = (file, setCsvObject) => {
  let height, width;
  let obj = {
    HEIGHT: 0,
    WIDTH: 0,
    csv: [],
  };

  obj.csv = [];

  let sptLine = file.split(/\r\n|\n/);
  console.log(sptLine);

  height = 0;
  for(let line of sptLine)
  {
    if(line === "") continue;

    let spt = mySplit(line, DELIMITER, APOSTROPHE);

    obj.csv.push(spt);
    height++;
  }

  width = obj.csv[0].length;

  obj.HEIGHT = height;
  obj.WIDTH = width;

  setCsvObject(obj);

  return;
}

export const makeTable = (csvObject, height, width) => {
  let table = [];
    
  for(let h = 0; h < csvObject.HEIGHT; h++)
  {
    let line = [];
    for(let w = 0; w < csvObject.WIDTH; w++) line.push(csvObject.csv[h][w]);
    for(let w = 0; w < width; w++) line.push("");

    table.push(line);
  }

  for(let h = 0; h < height; h++) 
  {
    let dummy = [];
    for(let w = 0; w < csvObject.WIDTH + width; w++) dummy.push("");
    table.push(dummy);
  }

  return table;
}

export const downLoadCsv = (contents, fileName = "MyFile.csv") => {
  let fileDown = "data:csv;charset=utf-8," + contents;

  let encodedUri = encodeURI(fileDown);
  let link = document.createElement("a");

  link.setAttribute("href", encodedUri);
  link.setAttribute("download", fileName);

  document.body.appendChild(link);

  link.click();

  document.body.removeChild(link);
};

export const rowToAlpha = (row) => {
  const numToAlpha = [
    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
    "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
    "U", "V", "W", "X", "Y", "Z",
  ];

  if (row <= 26) return numToAlpha[row - 1];

  if (26 < row && row <= 26 * 26) {
    let front = numToAlpha[parseInt(row / 26) - 1];
    let back = numToAlpha[(row % 26) - 1];

    if (row % 26 === 0) {
      back = "Z";
      front = numToAlpha[parseInt(row / 26) - 2];
    }

    return front + back;
  }

  return "too_long";
}

이전 - (14) handsontable methods

다음 - (16) colWidths 옵션 / csv 파일 파싱 보완

반응형

댓글