본문 바로가기
프로젝트/토이 프로젝트(개인)

[JavaScript/HTML]HTML과 바닐라 JS로 웹 시트를 구현하기

by 으노으뇨 2025. 2. 6.
728x90
반응형
SMALL

안녕하세요~! 정ㅇㅇㅇ@@말 오랜만에 포스트를 작성합니다.ㅠㅠ

요즘 업계도 불황이고, 개발자의 연봉도 예전과 같이 않네요. 그러니 더 힘내서 연구하고 공부해야하는 시기이지 않을까..

그리고 기업에선 계속 인력감축 과 동시에 지출감소이 이루어지고있죠ㅠㅠ그래서 결국... 예산을 줄이다 줄이다 못해..

HTML과 JavaScript로만 웹 시트를 구현해놔라!!

요구사항

현재 웹 개발자로 근무중인 A씨의 회사의 사정이 어려워지자, 회사는 결국 마이크로소프트 엑셀 라이센스를 구독할 자금 마저 아끼기 위해 연봉 2800만원을 주고 계약한 초급 개발자 A씨에게 웹으로 엑셀을 구현해달라고 지시했다.

회사는 개발자는 다 구현할 줄 알아야 하지 않냐며 아래 같이 가스라이팅을 하고 앉아있다.

돈 받고 일하는 사람은 아마추어가 아니다. 넌 돈받고 일하니 프로다! 그럼 해야지ㅋ 아님 나가등가 ㅋ아ㅋㅋ

기본적인 엑셀시트를 구현하고 함수는 써보면서 사용할 것만 차차 추가하자고 요구사항이 들어왔다.

기본적인 엑셀시트의 기능으로는

  • 엑셀처럼 테이블 형식의 값입력이 자유로워야 한다.
  • 각 행과 열이 선택이 자유로워야한다.
  • 행과 열을 블록처럼 지정하여 복사하고 붙여넣기가 수월해야한다.
  • 컬럼별로 필터를 적용하여 정렬기능과 필터 기능이 있어야한다.
  • 심지어 다중 정렬이 적용되어 A컬럼과 B컬럼간의 정렬이 초기화 되지 않게 중첩적으로 적용되어야한다.
  • 기존의 엑셀파일 또는 CSV 파일, 심지어 구글 시트와도 복붙이 용이해야한다.
  • 추가적으로 함수기능에 대해서는 엑셀사용자로부터 의견을 받아 계속 추가해나가야한다.

실제 구현 모습

먼저 실제 구현하는 모습을 보고 코드 설명을 하겠습니다. 

구현 데이터는 현재 데이터가 없어서 무작위 숫자를 생성해서 각 셀에 입력시킨뒤 실행되도록 구현했습니다.

시연 순서

  • 셀 다중선택
  • 셀 다중 선택 후 복사, 붙여놓기
  • 셀 다중 삭제
  • 셀값 변경후 정렬과 다중/중첩 정렬
  • 필터를 통해 데이터 출력 화면
  • 외부 엑셀 및 구글 시트 데이터 복사, 붙여넣기 기능

간단한 프로그램 설명

간단하게 기능별로 소스설명과 소스설명을 말씀드리겠습니다.

우선은 현재 데이터값이 없는 관계로 무작위 값으로 표를 채웠습니다. 

그 무작위 값을 채우기 위한 함수입니다.

1. 무작위 함수 값을 채워넣는 함수

getRandomNumber = () => Math.floor(Math.random() * 1000);

0부터 999 까지의 정수를 무작위로 생성하여 반환하도록 작성했고, 기본 JS 난수 생성 함수를 사용했다.

2. 테이블을 그리고, 숫자를 채워넣는 함수

initTable = () => {
  for (let i = 0; i < this.rows; i++) {
    const row = document.createElement('tr');
    row.dataset.originalIndex = i;
    for (let j = 0; j < this.cols; j++) {
      const cell = document.createElement('td');
      cell.contentEditable = 'true';
      cell.textContent = this.getRandomNumber();
      cell.addEventListener('click', () => this.clearPasted());
      row.appendChild(cell);
    }
    this.tableBody.appendChild(row);
  }
};

지정된 행과 열의 수만큼 HTML 테이블의 tr과 td 요소를 생성하고, 각 셀에 무작위 숫자를 채워넣고, 원래 순서를 data-original-index이라는 속성에 저장하도록 했습니다.

동작 원리

  • 이 함수는 중첩 for문을 사용하여 각 행마다 <tr>를 생성하고, 그 내부에 지정된 열 수만큼 <td> 셀을 생성합니다.
  • 각 셀은 contentEditable 속성이 true로 설정되어 있어 사용자가 직접 내용을 편집할 수 있습니다.
  • 각 셀에 대해 this.getRandomNumber()를 호출하여 임의의 숫자를 삽입하며, 클릭 시 clearPasted()를 호출하도록 이벤트를 등록합니다.

3. 마우스 드래그 셀 선택 이벤트 함수

initMouseEvents = () => {
  this.tableBody.addEventListener('mousedown', (event) => {
    if (event.target.tagName !== 'TD') return;
    this.isMouseDown = true;
    this.startCell = event.target;
    this.clearSelection();
    event.target.classList.add('selected');
  });

  this.tableBody.addEventListener('mouseover', (event) => {
    if (!this.isMouseDown || event.target.tagName !== 'TD') return;
    this.clearSelection();
    this.endCell = event.target;
    const startRow = Math.min(this.startCell.parentNode.rowIndex, this.endCell.parentNode.rowIndex);
    const endRow = Math.max(this.startCell.parentNode.rowIndex, this.endCell.parentNode.rowIndex);
    const startCol = Math.min(this.startCell.cellIndex, this.endCell.cellIndex);
    const endCol = Math.max(this.startCell.cellIndex, this.endCell.cellIndex);
    for (let i = startRow; i <= endRow; i++) {
      for (let j = startCol; j <= endCol; j++) {
        this.tableBody.rows[i - 1].cells[j].classList.add('selected');
      }
    }
  });

  this.tableBody.addEventListener('mouseup', () => {
    this.isMouseDown = false;
  });
  document.body.addEventListener('mouseup', this.highlightCells);
};

 

동작 원리

  • mousedown 이벤트에서는 사용자가 마우스 버튼을 누른 셀을 startCell로 기록하고, 기존의 선택 영역을 초기화합니다.
  • mouseover 이벤트에서는 드래그 중인 동안 startCell과 현재 마우스 위치의 셀(endCell) 사이의 사각형 영역을 계산하여 해당 영역의 모든 셀에 .selected 클래스를 부여합니다.
  • mouseup 이벤트에서는 마우스 드래그를 종료하고, 최종적으로 highlightCells()를 호출해 시각적으로 선택된 셀을 강조합니다.

알고리즘

  • 두 번의 for문으로 사각형 영역을 순회하는데, 이 범위는 드래그 영역에 따라 달라지며, 평균적으로 O(n*m) (n: 행의 수, m: 열의 수) 연산을 수행합니다.
  • 드래그 영역이 작다면 큰 부담 없이 동작하지만, 전체 테이블을 드래그하면 최악의 경우 전체 셀을 순회합니다.

이슈 사항

  • 빠른 드래그 시 DOM 업데이트가 빈번하게 발생할 수 있으므로, 성능 최적화를 위해 디바운싱(debouncing)을 고려할 수 있으나, 현재 규모에서는 큰 문제가 되지 않습니다.

4. 복사, 붙여넣기, Delete 키에 대한 이벤트 함수들

initCopyPasteEvents = () => {
  document.addEventListener('copy', (event) => {
    const selectedCells = document.querySelectorAll('.selected');
    if (selectedCells.length === 0) return;
    event.preventDefault();
    this.clearCopied();
    this.copiedData = [];
    let currentRow = selectedCells[0].parentNode.rowIndex - 1;
    let rowValues = [];
    selectedCells.forEach(cell => {
      if (cell.parentNode.rowIndex - 1 !== currentRow) {
        this.copiedData.push(rowValues);
        rowValues = [];
        currentRow = cell.parentNode.rowIndex - 1;
      }
      rowValues.push(cell.textContent);
      cell.classList.add('copied');
    });
    this.copiedData.push(rowValues);
    event.clipboardData.setData('text/plain', this.copiedData.map(row => row.join('\t')).join('\n'));
    this.highlightCells();
  });

  document.addEventListener('paste', (event) => {
    event.preventDefault();
    const clipboardText = event.clipboardData.getData('text/plain');
    if (!clipboardText) return;
    const rowsData = clipboardText.split('\n').map(row => row.split('\t'));
    const selectedCells = document.querySelectorAll('.selected');
    if (selectedCells.length === 0) return;
    const startRow = selectedCells[0].parentNode.rowIndex - 1;
    const startCol = selectedCells[0].cellIndex;
    rowsData.forEach((rowData, i) => {
      rowData.forEach((cellData, j) => {
        const rowIndex = startRow + i;
        const colIndex = startCol + j;
        if (rowIndex < this.tableBody.rows.length && colIndex < this.tableBody.rows[rowIndex].cells.length) {
          this.tableBody.rows[rowIndex].cells[colIndex].textContent = cellData;
          this.tableBody.rows[rowIndex].cells[colIndex].classList.add('pasted');
        }
      });
    });
    this.highlightCells();
  });

  document.addEventListener('keydown', (event) => {
    if (event.key === 'Delete') {
      document.querySelectorAll('.selected').forEach(cell => cell.textContent = '');
      event.preventDefault();
    }
    this.highlightCells();
  });
};

 

 

복사

  • 선택된 셀을 순회하며, 행이 바뀔 때마다 새로운 배열에 값을 담습니다.
  • clipboardData.setData()를 사용해 텍스트를 클립보드에 저장하는데, 각 셀 값은 탭(\t)으로 구분되고 각 행은 줄바꿈(\n)으로 구분됩니다.
  • 각 셀에 .copied 클래스를 추가해 시각적 피드백을 제공합니다.

붙여넣기

  • 클립보드의 텍스트 데이터를 파싱하여 이차원 배열로 변환한 후, 선택 영역의 시작 셀부터 차례로 셀에 채워 넣습니다.
  • 붙여넣기 시 각 셀에 .pasted 클래스를 부여하여 시각적 피드백을 제공합니다.

Delete키 이벤트

  • 선택된 셀의 텍스트를 빈 문자열로 변경합니다.

알고리즘 및 메모리 사용

  • 복사 및 붙여넣기 작업은 선택된 셀 수에 따라 O(n) 또는 O(n*m)의 시간 복잡도를 가지며, 메모리는 클립보드 데이터와 임시 배열에 한정되어 있어 큰 부담은 없습니다.

이슈 사항

  • DOM 접근이 빈번하게 일어나므로, 대량의 데이터에서는 성능 최적화가 필요할 수 있습니다.

5. 정렬 버튼 이벤트 함수

initSortEvents = () => {
  const sortBtns = document.querySelectorAll('.sort-btn');
  sortBtns.forEach(btn => {
    btn.addEventListener('click', (event) => {
      this.clearSelection();
      this.clearCopied();
      this.clearPasted();
      const colIndex = parseInt(btn.getAttribute('data-col-index'));
      let sortState = btn.getAttribute('data-sort'); // 'none', 'asc', 'desc'
      if (sortState === 'none' || sortState === null) {
        sortState = 'asc';
        btn.textContent = '↑';
        btn.style.color = 'blue';
      } else if (sortState === 'asc') {
        sortState = 'desc';
        btn.textContent = '↓';
        btn.style.color = 'red';
      } else if (sortState === 'desc') {
        sortState = 'none';
        btn.textContent = '⇅';
        btn.style.color = 'black';
      }
      btn.setAttribute('data-sort', sortState);
      this.sortCriteria = this.sortCriteria.filter(c => c.colIndex !== colIndex);
      if (sortState !== 'none') this.sortCriteria.push({ colIndex, sort: sortState });
      const rowsArray = Array.from(this.tableBody.querySelectorAll('tr'));
      if (this.sortCriteria.length > 0) {
        rowsArray.sort((a, b) => {
          for (let criterion of this.sortCriteria) {
            const { colIndex, sort } = criterion;
            const cellA = a.cells[colIndex].textContent.trim();
            const cellB = b.cells[colIndex].textContent.trim();
            const numA = parseFloat(cellA);
            const numB = parseFloat(cellB);
            let comp = 0;
            if (!isNaN(numA) && !isNaN(numB)) {
              comp = numA - numB;
            } else {
              comp = cellA.localeCompare(cellB);
            }
            if (comp !== 0) return sort === 'asc' ? comp : -comp;
          }
          return parseInt(a.dataset.originalIndex) - parseInt(b.dataset.originalIndex);
        });
      } else {
        rowsArray.sort((a, b) => parseInt(a.dataset.originalIndex) - parseInt(b.dataset.originalIndex));
      }
      rowsArray.forEach(row => this.tableBody.appendChild(row));
      this.highlightCells();
    });
  });
};

 

 

정렬 버튼 클릭 시 해당 컬럼에 대해 정렬 조건을 토글합니다.

'none' → 'asc' (오름차순, 파란색, ↑)

'asc' → 'desc' (내림차순, 빨간색, ↓)

'desc' → 'none' (정렬 해제, 검은색, ⇅)

다중 정렬 조건을 적용하여 전체 행들을 재정렬합니다.

 

동작 원리

  1. 각 정렬 버튼의 data-sort 속성을 읽어 현재 정렬 상태를 결정합니다.
  2. 상태에 따라 버튼 텍스트와 색상을 업데이트한 후, 정렬 조건 배열(sortCriteria)에 추가 또는 제거합니다.
  3. 모든 <tr> 요소를 배열로 변환한 후, 다중 정렬 조건에 따라 비교함수를 사용하여 정렬합니다.
  4. 정렬 비교 함수는 우선 정렬 조건 배열 순서대로 각 셀의 값을 비교합니다.
    (숫자인 경우, parseFloat를 이용해 숫자 비교를 수행하고, 그렇지 않으면 localeCompare로 문자열 비교를 수행)
  5. 모든 조건이 동일하면, 원래 생성 순서(data-original-index)를 기준으로 정렬합니다.

알고리즘

  • 정렬은 평균적으로 O(n log n) 시간 복잡도를 가집니다.
  • 다중 조건 비교는 각 비교마다 O(k) (k: 정렬 조건의 수)이며, k는 보통 작으므로 큰 부담은 없습니다.

메모리 사용 및 이슈

  • 배열 변환 및 정렬 과정에서 새로운 배열을 생성하므로, 테이블 행 수가 매우 많으면 메모리 사용량 및 성능에 영향을 줄 수 있습니다.
  • DOM 조작(appendChild)을 반복해서 호출하는 부분은 브라우저 렌더링에 영향을 줄 수 있으므로, 가능한 한 한 번의 DOM 업데이트로 처리하는 것이 좋습니다.

6. 필터 이벤트 함수

initFilterEvents = () => {
      const filterBtns = document.querySelectorAll('.filter-btn');
      filterBtns.forEach(btn => {
        btn.addEventListener('click', (event) => {
          this.clearSelection();
          this.clearCopied();
          this.clearPasted();
          this.removeFilterModal();
          const colIndex = parseInt(btn.getAttribute('data-col-index'));
          const uniqueValues = new Set();
          Array.from(this.tableBody.querySelectorAll('tr')).forEach(row => {
            const val = row.cells[colIndex].textContent.trim();
            uniqueValues.add(val);
          });
          const uniqueArray = Array.from(uniqueValues).sort((a, b) => {
            const numA = parseFloat(a), numB = parseFloat(b);
            return (isNaN(numA) || isNaN(numB)) ? a.localeCompare(b) : numA - numB;
          });
          let currentFilter = this.filters[colIndex] || new Set(uniqueArray);
          this.filters[colIndex] = currentFilter;
          this.lastUnique[colIndex] = new Set(uniqueArray);
          const modal = document.createElement('div');
          modal.className = 'filter-modal';
          modal.style.width = '170px';
          const searchContainer = document.createElement('div');
          searchContainer.style.display = 'flex';
          searchContainer.style.alignItems = 'center';
          searchContainer.style.justifyContent = 'space-between';
          searchContainer.style.marginBottom = '5px';
          const searchInput = document.createElement('input');
          searchInput.type = 'text';
          searchInput.placeholder = '검색...';
          searchInput.style.flex = '1';
          searchInput.style.boxSizing = 'border-box';
          const selectAllCheckbox = document.createElement('input');
          selectAllCheckbox.type = 'checkbox';
          selectAllCheckbox.checked = (currentFilter.size === uniqueArray.length);
          searchContainer.appendChild(selectAllCheckbox);
          searchContainer.appendChild(searchInput);
          modal.appendChild(searchContainer);
          searchInput.addEventListener('keyup', () => {
            const term = searchInput.value.trim().toLowerCase();
            const listItems = modal.querySelectorAll('ul li');
            listItems.forEach(li => {
              const text = li.textContent.trim().toLowerCase();
              li.style.display = text.includes(term) ? '' : 'none';
            });
          });
          const ul = document.createElement('ul');
          uniqueArray.forEach(val => {
            const li = document.createElement('li');
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = currentFilter.has(val);
            checkbox.dataset.value = val;
            checkbox.addEventListener('change', () => {
              this.updateFilter(colIndex);
              this.updateSelectAllCheckboxState(colIndex, selectAllCheckbox);
            });
            const label = document.createElement('label');
            label.textContent = ' ' + val;
            li.appendChild(checkbox);
            li.appendChild(label);
            ul.appendChild(li);
          });
          modal.appendChild(ul);
          document.body.appendChild(modal);
          selectAllCheckbox.addEventListener('change', () => {
            const checkboxes = modal.querySelectorAll('ul li input[type=\"checkbox\"]');
            checkboxes.forEach(chk => chk.checked = selectAllCheckbox.checked);
            this.updateFilter(colIndex);
          });
          const btnRect = btn.getBoundingClientRect();
          modal.style.top = (btnRect.bottom + window.scrollY) + 'px';
          modal.style.left = (btnRect.left + window.scrollX) + 'px';
          setTimeout(() => document.addEventListener('click', this.outsideClickListener), 0);
        });
      });
    };
    
updateFilter = (colIndex) => {
  const modal = document.querySelector('.filter-modal');
  if (!modal) return;
  const checkboxes = modal.querySelectorAll('ul li input[type="checkbox"]');
  const allowed = new Set();
  checkboxes.forEach(chk => { if (chk.checked) allowed.add(chk.dataset.value); });
  this.filters[colIndex] = allowed;
  this.applyFilters();
};

 

 

필터 버튼 클릭 시, 컬럼의 최신 데이터를 스캔하여 유니크한 값들의 리스트를 생성하도록 했습니다.

검색 입력 필드와 전체 선택 체크박스가 포함된 모달이 나타나도록 만들었습니다.

개별 체크박스의 상태에 따라 필터 조건을 업데이트하고, 테이블에 적용하도록 했습니다.

동작 원리

  1. 데이터 스캔
    - 테이블의 모든 행에서 지정한 컬럼의 값을 읽어와 `Set`에 저장합니다.
    - `Set`을 사용하여 중복된 값들을 제거한 후, 정렬하여 `uniqueArray`에 저장합니다.

  2. 필터 상태 유지
    - 기존 필터 조건(`this.filters[colIndex]`)이 있다면 그대로 사용하고, 없다면 기본적으로 모든 값이 허용된 `Set`으로 초기화합니다.
    - 최신 데이터를 `this.lastUnique[colIndex]`에 저장해, 나중에 버튼 색상 업데이트 시 비교에 사용합니다.
  3. 모달 생성
    - 모달은 `<div>` 요소로 생성되며, CSS 클래스를 이용해 스타일을 지정합니다.
    -모달 상단에 검색 입력 필드와 전체 선택 체크박스를 포함하는 컨테이너를 생성합니다.
  4. 검색 기능
    - 검색 입력 필드에 키업 이벤트를 등록하여, 사용자가 입력한 텍스트와 각 리스트 항목의 텍스트를 비교합니다.
    -일치하지 않는 항목은 `display: none`으로 숨깁니다.
  5. 필터 리스트 생성
    - 각 유니크 값마다 <li> 요소를 생성하고, 그 안에 체크박스와 라벨을 추가합니다.
    - 체크박스 상태 변경 시, `updateFilter` 메서드를 호출하여 해당 컬럼의 필터 조건을 업데이트합니다.
  6. 전체 선택 기능
    - 전체 선택 체크박스에 이벤트를 등록하여, 체크 시 모달 내 모든 체크박스가 선택되도록 하고, 해제 시 모두 해제되도록 합니다.
  7. 모달 위치 및 외부 클릭
    - 모달은 필터 버튼의 위치를 기준으로 하단에 위치시키며, 외부 클릭 시`outsideClickListener`를 통해 모달을 제거합니다.

7. 테이블의 각 행 순회 후  필터 행 표시 함수

applyFilters = () => {
  Array.from(this.tableBody.querySelectorAll('tr')).forEach(row => {
    let visible = true;
    for (let col in this.filters) {
      const allowed = this.filters[col];
      const cellValue = row.cells[col].textContent.trim();
      if (!allowed.has(cellValue)) { 
        visible = false; 
        break; 
      }
    }
    row.style.display = visible ? '' : 'none';
  });
  this.updateFilterButtonStyles();
};

 

동작 원리

  • 각 행에 대해, 모든 필터 조건(각 컬럼마다 적용된 필터)을 체크합니다.
  • 하나라도 조건에 맞지 않으면 해당 행을 숨깁니다.
  • 모든 조건을 만족하면 행을 보이도록 합니다.
  • 이후, updateFilterButtonStyles()를 호출하여 필터가 적용된 컬럼의 버튼 색상을 변경합니다.

8. (필터 모달) 전체 선택 이벤트 함수

updateSelectAllCheckboxState = (colIndex, selectAllCheckbox) => {
  const modal = document.querySelector('.filter-modal');
  if (!modal) return;
  const checkboxes = modal.querySelectorAll('ul li input[type="checkbox"]');
  const totalCount = checkboxes.length;
  const checkedCount = Array.from(checkboxes).filter(chk => chk.checked).length;
  selectAllCheckbox.checked = (checkedCount === totalCount);
};

 

모달 내의 모든 체크박스의 상태를 확인하여, 전체 선택 체크박스의 상태를 업데이트

동작 원리

  • 모달에서 모든 체크박스 요소를 가져와, 체크된 항목의 수를 계산합니다.
  • 만약 체크된 항목의 수가 전체 항목 수와 같다면, 전체 선택 체크박스에 체크된 상태를 설정합니다.
  • 그렇지 않으면 체크 해제 상태로 둡니다.

9. 모달 외부 클릭시 모달 닫기 이벤트 함수

outsideClickListener = (event) => {
  const modal = document.querySelector('.filter-modal');
  if (modal && !modal.contains(event.target) && !event.target.classList.contains('filter-btn')) {
    this.removeFilterModal();
  }
};

removeFilterModal = () => {
  const modal = document.querySelector('.filter-modal');
  if (modal) {
    modal.parentNode.removeChild(modal);
    document.removeEventListener('click', this.outsideClickListener);
  }
};

outsideClickListener : 모달 외부를 클릭하면 필터 모달을 닫는다.

removeFilterModal : 모달을 DOM에서 제거하고, 외부 클릭 이벤트 리스너를 해제

동작 원리

  • outsideClickListener는 이벤트가 발생한 대상(event.target)이 필터 모달 내부 또는 필터 버튼이 아닌 경우, 모달을 제거합니다.
  • removeFilterModal은 모달 요소가 존재하면 부모 노드에서 제거하고, 이벤트 리스너를 해제하여 메모리 누수를 방지합니다.

10 .각 필터 버튼 색상 업데이트 함수

updateFilterButtonStyles = () => {
  document.querySelectorAll('.filter-btn').forEach(btn => {
    const colIndex = parseInt(btn.getAttribute('data-col-index'));
    if (this.filters[colIndex] && this.filters[colIndex].size < this.lastUnique[colIndex].size) {
      btn.style.color = 'red'; // 필터 조건이 적용되었음을 표시
    } else {
      btn.style.color = 'black';
    }
  });
};

필터 조건이 일부만 적용된 경우 버튼 색상을 빨간색으로, 그렇지 않으면 검은색으로 표시합니다.

동작 원리

  • 각 필터 버튼의 data-col-index를 확인한 후, 해당 컬럼의 필터 조건(this.filters[colIndex])과 전체 유니크 값(this.lastUnique[colIndex])을 비교합니다.
  • 만약 현재 필터 조건의 크기가 전체 유니크 값보다 작으면(즉, 일부 값이 제외되었다면) 버튼 색상을 빨간색으로 변경합니다.

#. 유틸리티 함수들: clearSelection, clearCopied, clearPasted, highlightCells

 
clearSelection = () => {
  document.querySelectorAll('.selected').forEach(cell => cell.classList.remove('selected'));
};

clearCopied = () => {
  document.querySelectorAll('.copied').forEach(cell => {
    cell.classList.remove('copied');
    cell.style.backgroundColor = '#ffff9b';
  });
};

clearPasted = () => {
  document.querySelectorAll('.pasted').forEach(cell => {
    cell.classList.remove('pasted');
    cell.style.backgroundColor = '';
  });
};

highlightCells = () => {
  document.querySelectorAll('td').forEach(cell => {
    cell.style.backgroundColor = cell.classList.contains('selected') ? '#d3d3d3' : '';
  });
};

 

  • clearSelection: 선택된 셀의 .selected 클래스를 제거합니다.
  • clearCopied: .copied 클래스와 관련 스타일을 초기화합니다.
  • clearPasted: .pasted 클래스와 관련 스타일을 초기화합니다.
  • highlightCells: 모든 셀을 순회하며, 선택된 셀의 배경색을 지정합니다.

이 코드들을 짧은 시간 내에 작성하면서 많은 오류가 숨어져 있었습니다.

그리고 만들면서 계속 기능 구현에 대한 욕심이 생겼습니다.

  1. 외부 csv, 엑셀파일을 불러오기해서 그려주기
  2. 무작정 엑셀을 따라한 프로그램이 아닌 이제 기업에 특화된 장부화 하기
  3. 고정된 크기의 table이 아닌 넓히거나 줄일수 있도록 수정
  4. 작성된 데이터를 엑셀 또는 csv로 추출
  5. 열을 A~K로 했지만 행은 현재 숫자로 구현을 안했다. 그리고 열과 행을 누르면 해당 열과 행이 전부 선택되는 이벤트

까지 구현을 모두 해야 웹에서 간단하게 작성정도 될 수 있는 형태의 프로그램이지 않을까 싶습니다.

추후에 위 기능을 모두 구현하여 간단한 토이프로그램 게시판에 올리겠습니다.

아래는 전체 코드입니다.

간단합니다. css, html, js 로 나누어지며 그대로 복붙해서 사용하시면 되겠습니다.

CSS 

    /* 기본 테이블 스타일 */
table {
  width: 100%;
  border-collapse: collapse;
  font-family: Arial, sans-serif;
  font-size: 14px;
  user-select: none;
  table-layout: fixed;
}

/* 헤더 스타일 */
th {
  background-color: #f1f3f4;
  border: 1px solid #dadce0;
  padding: 8px;
  text-align: center;
  font-weight: bold;
  position: relative;
  white-space: nowrap;
}

/* 셀 스타일 */
td {
  border: 1px solid #dadce0;
  padding: 8px;
  text-align: right;
  background-color: white;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 선택된 셀 스타일 */
.selected {
  background-color: rgba(26, 115, 232, 0.1);
  outline: 2px solid #1a73e8;
  outline-offset: -2px;
}

/* 복사된 셀 스타일 */
.copied {
  background-color: #fff9c4;
}

/* 붙여넣은 셀 스타일 */
.pasted {
  background-color: #e8f5e9;
}

/* 정렬 버튼 스타일 */
.sort-btn, .filter-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 0.8rem;
  margin-left: 5px;
  color: #5f6368;
}

/* 필터 모달 스타일 */
.filter-modal {
  position: absolute;
  background: white;
  border: 1px solid #dadce0;
  box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2);
  max-height: 200px;
  overflow-y: auto;
  padding: 8px;
  z-index: 1000;
  font-size: 13px;
}

/* 필터 리스트 스타일 */
.filter-modal ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.filter-modal li {
  margin: 2px 0;
}

/* 입력 가능한 셀 */
td[contenteditable="true"] {
  cursor: text;
}

/* 활성화된 셀 (편집 중) */
td:focus {
  outline: 2px solid #1a73e8;
  background-color: white;
}

HTML(Body 태그만 올리겠음)

<body>
  <table id="excelTable">
    <thead>
      <tr>
        <!-- 헤더에 정렬 버튼과 필터 버튼을 추가합니다. -->
        <th>
          A
          <button class="sort-btn" data-col-index="0" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="0">⚲</button>
        </th>
        <th>
          B
          <button class="sort-btn" data-col-index="1" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="1">⚲</button>
        </th>
        <th>
          C
          <button class="sort-btn" data-col-index="2" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="2">⚲</button>
        </th>
        <th>
          D
          <button class="sort-btn" data-col-index="3" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="3">⚲</button>
        </th>
        <th>
          E
          <button class="sort-btn" data-col-index="4" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="4">⚲</button>
        </th>
        <th>
          F
          <button class="sort-btn" data-col-index="5" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="5">⚲</button>
        </th>
        <th>
          G
          <button class="sort-btn" data-col-index="6" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="6">⚲</button>
        </th>
        <th>
          H
          <button class="sort-btn" data-col-index="7" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="7">⚲</button>
        </th>
        <th>
          I
          <button class="sort-btn" data-col-index="8" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="8">⚲</button>
        </th>
        <th>
          J
          <button class="sort-btn" data-col-index="9" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="9">⚲</button>
        </th>
        <th>
          K
          <button class="sort-btn" data-col-index="10" data-sort="none">⇅</button>
          <button class="filter-btn" data-col-index="10">⚲</button>
        </th>
      </tr>
    </thead>
    <tbody>
    </tbody>
  </table>
</body>

JavaScript

document.addEventListener('DOMContentLoaded', () => {
  class Spreadsheet {
    constructor(containerId, rowCount, colCount) {
      this.tableBody = document.querySelector(`#${containerId} tbody`);
      this.rowCount = rowCount;
      this.colCount = colCount;
      this.isSelecting = false;
      this.selectionStart = null;
      this.selectionEnd = null;
      this.copiedData = [];
      this.sortRules = [];
      this.filters = {};
      this.uniqueValues = {};

      this.createTable();
      this.setupSelection();
      this.setupClipboard();
      this.setupSorting();
      this.setupFiltering();
    }

    generateRandomValue = () => Math.floor(Math.random() * 1000);

    createTable = () => {
      for (let i = 0; i < this.rowCount; i++) {
        const row = document.createElement('tr');
        row.dataset.index = i;
        for (let j = 0; j < this.colCount; j++) {
          const cell = document.createElement('td');
          cell.contentEditable = 'true';
          cell.textContent = this.generateRandomValue();
          cell.addEventListener('click', () => this.clearPasted());
          row.appendChild(cell);
        }
        this.tableBody.appendChild(row);
      }
    };

    setupSelection = () => {
      this.tableBody.addEventListener('mousedown', (event) => {
        if (event.target.tagName !== 'TD') return;
        this.isSelecting = true;
        this.selectionStart = event.target;
        this.clearHighlight();
        event.target.classList.add('selected');
      });

      this.tableBody.addEventListener('mouseover', (event) => {
        if (!this.isSelecting || event.target.tagName !== 'TD') return;
        this.clearHighlight();
        this.selectionEnd = event.target;
        const startRow = Math.min(this.selectionStart.parentNode.rowIndex, this.selectionEnd.parentNode.rowIndex);
        const endRow = Math.max(this.selectionStart.parentNode.rowIndex, this.selectionEnd.parentNode.rowIndex);
        const startCol = Math.min(this.selectionStart.cellIndex, this.selectionEnd.cellIndex);
        const endCol = Math.max(this.selectionStart.cellIndex, this.selectionEnd.cellIndex);
        for (let i = startRow; i <= endRow; i++) {
          for (let j = startCol; j <= endCol; j++) {
            this.tableBody.rows[i - 1].cells[j].classList.add('selected');
          }
        }
      });

      this.tableBody.addEventListener('mouseup', () => {
        this.isSelecting = false;
      });
      document.body.addEventListener('mouseup', this.applyHighlight);
    };

    setupClipboard = () => {
      document.addEventListener('copy', (event) => {
        const selectedCells = document.querySelectorAll('.selected');
        if (selectedCells.length === 0) return;
        event.preventDefault();
        this.clearCopied();
        this.copiedData = [];
        let rowIndex = selectedCells[0].parentNode.rowIndex - 1;
        let rowValues = [];
        selectedCells.forEach(cell => {
          if (cell.parentNode.rowIndex - 1 !== rowIndex) {
            this.copiedData.push(rowValues);
            rowValues = [];
            rowIndex = cell.parentNode.rowIndex - 1;
          }
          rowValues.push(cell.textContent);
          cell.classList.add('copied');
        });
        this.copiedData.push(rowValues);
        event.clipboardData.setData('text/plain', this.copiedData.map(row => row.join('\t')).join('\n'));
        this.applyHighlight();
      });

      document.addEventListener('paste', (event) => {
        event.preventDefault();
        const clipboardText = event.clipboardData.getData('text/plain');
        if (!clipboardText) return;
        const rowsData = clipboardText.split('\n').map(row => row.split('\t'));
        const selectedCells = document.querySelectorAll('.selected');
        if (selectedCells.length === 0) return;
        const startRow = selectedCells[0].parentNode.rowIndex - 1;
        const startCol = selectedCells[0].cellIndex;
        rowsData.forEach((rowData, i) => {
          rowData.forEach((cellData, j) => {
            const rowIndex = startRow + i;
            const colIndex = startCol + j;
            if (rowIndex < this.tableBody.rows.length && colIndex < this.tableBody.rows[rowIndex].cells.length) {
              this.tableBody.rows[rowIndex].cells[colIndex].textContent = cellData;
              this.tableBody.rows[rowIndex].cells[colIndex].classList.add('pasted');
            }
          });
        });
        this.applyHighlight();
      });

      document.addEventListener('keydown', (event) => {
        if (event.key === 'Delete') {
          document.querySelectorAll('.selected').forEach(cell => cell.textContent = '');
          event.preventDefault();
        }
        this.applyHighlight();
      });
    };

    setupSorting = () => {
      document.querySelectorAll('.sort-btn').forEach(btn => {
        btn.addEventListener('click', () => {
          this.clearHighlight();
          this.clearCopied();
          this.clearPasted();
          const colIndex = parseInt(btn.getAttribute('data-col-index'));
          let sortState = btn.getAttribute('data-sort');
          if (sortState === 'none' || sortState === null) {
            sortState = 'asc';
          } else if (sortState === 'asc') {
            sortState = 'desc';
          } else {
            sortState = 'none';
          }
          btn.setAttribute('data-sort', sortState);
          this.sortRules = this.sortRules.filter(c => c.colIndex !== colIndex);
          if (sortState !== 'none') this.sortRules.push({ colIndex, sort: sortState });
          const rowsArray = Array.from(this.tableBody.querySelectorAll('tr'));
          if (this.sortRules.length > 0) {
            rowsArray.sort((a, b) => {
              for (let criterion of this.sortRules) {
                const { colIndex, sort } = criterion;
                const cellA = a.cells[colIndex].textContent.trim();
                const cellB = b.cells[colIndex].textContent.trim();
                const numA = parseFloat(cellA);
                const numB = parseFloat(cellB);
                let comp = isNaN(numA) || isNaN(numB) ? cellA.localeCompare(cellB) : numA - numB;
                if (comp !== 0) return sort === 'asc' ? comp : -comp;
              }
              return parseInt(a.dataset.index) - parseInt(b.dataset.index);
            });
          } else {
            rowsArray.sort((a, b) => parseInt(a.dataset.index) - parseInt(b.dataset.index));
          }
          rowsArray.forEach(row => this.tableBody.appendChild(row));
          this.applyHighlight();
        });
      });
    }
  }
  new Spreadsheet('excelTable', 32, 11);
});

 

긴글을 읽어주셔서 감사합니다. 코드에 대해 물어보셔도 좋습니다... 제가 너무 실력이 형편없어서 이 정도 밖에 못작성하지만.. 열씸히 하겠습니다 ㅠ

728x90
반응형
LIST

댓글