드래그로 선택 가능한 테이블 만들기 (feat. MouseEvent, TouchEvent)

전병민·2023년 5월 14일
5

프론트엔드

목록 보기
3/3
post-thumbnail

서론

시간표와 관련된 프로젝트에서 <table /> 요소를 마우스로 드래그해서 선택해야 하는 기능이 필요했습니다. 기능 구현을 위해 리서치를 진행했습니다. 유사 라이브러리에서 MouseEventTouchEvent 를 활용하는 방식으로 구현되어 있었습니다. 저 또한 이 기능을 구현하기 위해서 마우스 이벤트 및 터치 이벤트를 활용하기로 결정했습니다.

하지만 개발자는 어떻게 사용자가 드래그를 하고 있다고 판단할 수 있죠? 생각해보니 마우스 이벤트와 터치 이벤트에 대해 깊게 공부해본 적이 없다는 것을 깨닫게 되었습니다. 그토록 사용해온 click 이벤트가 MouseEvent 임에도 불구하고 말입니다! 기능 구현하기 위해 마우스 및 터치 이벤트에 대해 알아볼 필요가 있었습니다.

이번 포스트에서는 '드래그로 선택 가능한 테이블' 개발 과정 및 리팩토링 과정을 소개합니다. 또 겸사겸사 정리했던 마우스 이벤트 및 터치 이벤트에 대해서 소개합니다.

드래그로 선택 가능한 테이블 이라는 명칭이 너무 길기 때문에 지금부터는 드래그 테이블 이라고 명명하겠습니다.



구현 기능에 대한 이해

요구 사항 작성하기

어떻게 구현을 할 지 결정하기 전, 무엇을 구현해야 하는 지를 확실히 해둘 필요가 있었습니다. 드래그 테이블을 구현하기 위해 기능 요구 사항을 정리하였습니다.

기능 요구 사항

  1. 테이블의 선택 가능 범위가 N*M 크기라면 value 는 N*M 크기를 가져야 합니다.
  2. value 의 타입은 boolean[][] 이어야 합니다.
  3. value 는 외부에서 값을 받을 수 있어야 합니다.
  4. 테이블 내 임의의 셀의 값 x 에 대해, click 이 발생하면 해당 셀의 값은 !x 가 되어야 합니다.
    • false 일 때 click 하면 true 가 되어야 합니다.
    • true 일 때 click 하면 false 가 되어야 합니다.
  5. drag 할 때 처음 클릭한 셀의 값을 x 라고 한다면, 출발지 좌표와 목적지 좌표 사이에 존재하는 모든 셀의 값은 !x 가 되어야 합니다.
    • 출발지 좌표와 목적지 좌표 사이의 임의 값을 x 라고 한다면, 모든 x 에 대해서 다음의 식을 만족해야 합니다.
    • (startRow <= x.row <= endRow) && (startColumn <= x.column <= endColumn)
  6. 모든 디바이스 환경에서 동일하게 동작해야 합니다. 즉, 환경에 상관 없이 PC와 모바일 모두 clickdrag 이벤트를 지원해야 합니다.
    • 테이블을 드래그하는 동안 스크롤이 발생하면 안됩니다.

단순히 생각해봐도 드래그에는 click 이벤트만으로 충분치 않습니다. 확실한 한 가지는 드래그는 마우스(터치)를 이용해서 한다는 것입니다. 즉, MouseEventTouchEvent 를 알아볼 필요가 있었습니다.

PointerEvent 라는 것도 있지만, 모바일 디바이스 터치의 기본 동작인 스크롤을 비활성화 하기 위해서 TouchEvent 를 따로 처리할 필요가 있었습니다. 그래서 드래그 테이블을 구현할 때에는 MouseEventTouchEvent 를 사용하였습니다.

PointerEvent

옛날에는 MouseEvent 만 존재했습니다. 모바일 기기들이 나오면서 마우스로는 할 수 없던 것들(e.g. 멀티 터치)이 가능해졌습니다. 새로운 환경에 맞는 새로운 인터페이스가 필요했고 그것이 TouchEvent 입니다. 하지만 TouchEvent 로 충분하지 않은 상황(e.g. 스타일러스 펜)이 생겼습니다. 게다가 MouseEventTouchEvent 를 모두 사용하는 경우가 많아졌습니다. 이러한 한계들을 해결하기 위해서 PointerEvent 가 탄생했습니다.


MouseEvent 알아보기

click 이벤트 이외에 어떤 이벤트들이 MouseEvent 에 속할까요? 비슷한 이벤트끼리 묶어서 정리 해보았습니다.

mousedown / mouseup / click

세 가지 이벤트는 MouseEvent 중에서도 클릭과 관련이 깊습니다. mousedown 은 마우스 버튼이 눌리는 순간에 발생합니다. mouseup 은 마우스 버튼이 올라오는 순간에 발생합니다. clickmouseup 이후에 발생합니다. 즉, 사용자가 마우스를 클릭하면 이벤트는 다음과 같은 순서로 발생합니다.

mousedown -> mouseup -> click

mouseup vs click

mouseup 이 있는데 click 이 왜 필요하죠? 실제로 이 둘은 비슷한 일을 하는 것처럼 보입니다. 하지만 click 에는 몇 가지 조건이 필요합니다. 대표적으로 mousedownmouseup 이 같은 요소에서 발생해야 click 이벤트가 발생한다는 것이 있습니다. 이것이 clickmouseup 이후에 발생해야 하는 이유입니다. 이러한 조건 덕분에 click 이벤트를 사용하면 사용자 실수 방지와 같은 효과를 얻을 수 있습니다.

mousemove

마우스가 요소 위에서 움직일 때 발생합니다. mousemove 도 드래그를 구현하기에 훌륭하지만 mousemove 는 마우스를 움직일 때마다 발생하기 때문에, 최적화 작업이 특히 까다로울 것입니다. 우선 넘어가겠습니다.

mouseenter / mouseover

두 이벤트 모두 마우스 커서가 특정 요소 안으로 진입할 때 발생하는 이벤트입니다. 이 대목에서 두 이벤트는 테이블을 드래그하기에 적절할 것으로 보입니다. 다만, mouseenter 는 버블링이 일어나지 않습니다. 따라서 자식 요소로 커서를 옮겨도 이벤트가 다시 발생하지 않습니다. 그에 비해 mouseover 는 버블링이 일어나기 때문에 자식 요소에 커서를 옮기면 이벤트가 다시 발생합니다. 버블링을 활용하면 구현이 단순해지기 때문에 저는 mouseover 를 사용하기로 결정합니다.

mouseleave / mouseout

두 이벤트 모두 마우스 커서가 요소를 떠날 때 발생합니다. 그 외에는 mouseentermouseover 의 관계와 같습니다. mouseleave 는 버블링이 일어나지 않는 반면, mouseout 은 버블링이 일어납니다.

mouseenter 와 mouseleave

두 이벤트는 요소들를 감싸는 컨테이너의 hover 를 처리할 때 사용한 적이 있었습니다. mouseovermouseout 과 다르게 버블링이 일어나지 않는 것이 hover 를 처리하기에 적합했습니다.


TouchEvent 알아보기

작성한 요구 사항에는 사용자 환경에 상관없이 항상 드래그 기능을 제공해야 한다는 것이 포함되어 있습니다. 이를 위해 사용한 TouchEvent 에 대해 소개합니다.

touchstart / touchmove / touchend

touchstart 는 사용자의 손가락이 화면에 닿았을 때 발생합니다. touchmove 는 사용자의 손가락이 화면에서 움직이는 동안 계속 발생합니다. touchend 는 사용자의 손가락을 화면에서 뗄 때 발생합니다. touchstarttouchendmousedownmouseup 처럼 사용될 수 있지만, touchmovemouseover 보단 mousemove 와 유사합니다. 안타깝게도 mouseover 처럼 손가락이 특정 요소에 진입할 때 발생하는 이벤트는 없습니다.

다행히 다음과 같은 코드로 사용자 손가락이 위치한 곳의 요소를 가져올 수는 있습니다.

const { clientX, clientY } = e.touches[0];
target = document.elementFromPoint(clientX, clientY);

touches 는 사용자 손가락과 닿아있는 Touch 리스트를 반환합니다. Touch 는 프로퍼티로 clientXclientY 를 제공하니 elementFromPoint(clientX, clientY) 로 손가락 위치의 요소를 가져올 수 있습니다. 정확도도 준수했습니다.

스크롤 제어

touchmove 로 좌표값을 가져온다고 했지만, 만약 해당 요소가 스크롤이 가능하다면 어떻게 될까요? 당연히 스크롤과 touchmove 가 같이 실행되어 원하는대로 동작하지 않습니다. 테이블을 드래그하는 동안은 스크롤이 발생하면 안됩니다.

element.addEventListener('touchstart', (e) => {
	e.preventDefault()
});

touchstart 의 기본 동작으로 스크롤이 포함합니다. 따라서 e.preventDefault() 로 기본 동작을 취소해주어야 스크롤이 발생하지 않습니다.

이벤트 발생 순서

터치 이벤트를 사용할 때에는 몇 가지 주의해야 할 것이 있습니다. 모바일 디바이스에서 click 이벤트가 발생했던 것을 기억해보세요. 호환성 문제로 모바일 디바이스에서도 MouseEvent 들이 발생합니다. 브라우저마다 차이는 있지만 일반적으로 다음과 같은 순서로 발생합니다.

Touch for click
touchstart -> touchend -> mousemove -> mousedown -> mouseup -> click

Touch for move
touchstart -> touchmove -> touchend

핵심은 클릭을 위해 터치를 하면 터치 이벤트와 마우스 이벤트가 전부 일어날 수 있다는 것 입니다. 즉, TouchEventMouseEvent 를 동시에 사용할 때에는 특정 함수가 두 번 실행되지 않도록 특별히 처리해줘야 합니다.

Browser compatibility

TouchEvent 인터페이스 및 터치 이벤트들은 PC Safari, Opera 에서 지원되지 않습니다. 터치 이벤트 자체는 PC에서 발생할 일이 없으니 상관없지만 이벤트를 구분하기 위해서 인터페이스를 직접 사용할 때는 종종 있습니다. 가령 아래의 코드를 작성하면 Safari, Opera 에서 에러가 발생합니다. 따라서 두 번째 코드처럼 우회하여 터치 이벤트를 판별해야 합니다.

if (event instanceof TouchEvent) {
  console.log("이 이벤트는 터치 이벤트입니다.")
}
if ('ontouchstart' in window && event.type.startsWith('touch')) {
  console.log("이 이벤트는 터치 이벤트입니다.")
}

브라우저 전역객체에 ontouchstart 가 존재하는지와 이벤트 타입이 touch 시작하는지 확인하여 이벤트가 터치 이벤트인지 판별할 수 있습니다.

if (isMobile()) {
  console.log("이 부분은 모바일에서만 실행됩니다")
}

물론 이렇게 이벤트가 아니라 userAgent 를 가져와 모바일 디바이스인지를 판별하는 방법을 사용해도 되겠습니다.


정리하기

이제 제가 작성한 코드를 이해하기에 충분합니다. 본격적으로 코드를 설명하기 전에 위 정보를 종합하여 간략히 정리해보겠습니다.

구현에 사용할 이벤트

  • mousedown / touchstart : 드래그 시작할 때
  • mouseover / touchmove : 드래그 중일 때
  • mouseup / touchend : 드래그 끝날 때

구현 시 프로그램 요구 사항

  • <table /> 에 event listener 를 붙여놓고 <td /> 에서 발생한 이벤트 버블링을 활용하여 코드를 작성할 것
  • TouchEvent 인터페이스를 직접 사용하지 않을 것
  • 테이블을 드래그할 때에는 스크롤을 비활성화할 것
  • 터치 이벤트 직후 발생하는 마우스 이벤트가 같은 작업을 반복하지 않도록 만들 것


구현

지금부터는 기능 요구사항을 바탕으로 구현한 드래그 테이블 컴포넌트를 설명드리겠습니다. 핵심 로직과 관련이 없는 부분은 생략합니다.

드래그 테이블 컴포넌트 구현하기

function DraggableTable(initialValue) {  
  const startIndex = useRef<string>('');
  const currentIndex = useRef<string>('');
  const startTable = useRef<boolean[][]>([]);
  const mode = useRef<boolean>(false);
                                       
  const [value, setValue] = useState<boolean[][]>(initialValue);

  return (
    <Table>
      <THead>
        {/* ...생략 */}
      </THead>
      <TBody>
        {value.map((row, rowIndex) => (
          <TR key={rowIndex}>
              <TH>
                {time}
              </TH>
            {row.map(() => (
              <TD></TD>
            ))}
          </TR>
        ))}
      </TBody>
    </Table>
  );
}

드래그 테이블의 일부입니다. value 를 상태로 가지고 value 를 기반으로 테이블을 생성합니다. <Table /> 에 이벤트 처리기를 붙이고 핸들러 함수를 넣어주었습니다. 각 핸들러 함수에 필요한 정보를 담아두기 위해서 네 가지 ref 를 선언해두었습니다. 각각은 다음과 같은 역할을 수행합니다.

startIndex

드래그가 시작될 때, 드래그 시작 지점을 startIndex 에 기록합니다. 이 값은 다양한 역할을 수행할 수 있습니다. 드래그의 시작 지점이므로 드래그의 기준점이 될 수 있습니다. 또 만약 startIndexnull 이면 현재 드래그를 하고 있지 않다는 것을 의미합니다.

currentIndex

드래그를 하고 있을 때, 즉 handleDragMove 가 실행될 때 이전 인덱스와 현재 인덱스를 구별하기 위한 역할을 수행합니다.

startTable

드래그를 되돌릴 때, 드래그를 시작했을 때의 테이블 정보를 제공하는 역할을 수행합니다

mode

드래그가 시작될 때 처음 클릭한 셀이 false 라면 true 를 저장하고 truefalse 를 저장하여 현재 드래그의 모드를 기록합니다.


핸들러 함수 구현하기

handleDragStart

const handleDragStart = (e: Event) => {
  const index = getTableCellIndex(e);
  
  if (index === null) {
    return;
  }

  if (isMouseEvent(e) && e.buttons !== 1) {
    return;
  }

  if (isTouchEvent(e) && e.cancelable) {
    e.preventDefault();
  }

  const { rowIndex, colIndex } = index;

  startTable.current = [...value];
  startIndex.current = convertIndexToString(rowIndex, colIndex);
  mode.current = !value[rowIndex][colIndex];
  
  const newValue = [...value];
  newValue[rowIndex][colIndex] = mode.current;

  setValue(newValue);
};

드래그를 시작할 때 실행되는 함수입니다. 먼저 발생한 이벤트로부터 <td /> 의 row, col 인덱스를 가져옵니다. 위 함수를 요약하면 다음과 같습니다.

  1. 가져온 인덱스가 유효하지 않다면 함수를 종료합니다.
  2. 마우스 이벤트일 때, 버튼의 종류가 Primary button(좌 클릭)이 아니면 종료합니다.
  3. 이벤트의 종류가 터치 이벤트라면 드래그 동안 기본 동작인 스크롤을 비활성화합니다.
  4. startTable, startIndex, mode 에 각각 시작 테이블, 시작 인덱스, 드래그 모드를 기록합니다
  5. value 를 업데이트합니다.

handleDragMove

const handleDragMove = (e: Event) => {
  if (startIndex.current === '') {
    return;
  }

  if (isMouseEvent(e) && e.buttons !== 1) {
    return;
  }

  const index = getTableCellIndex(e);

  if (index === null) {
    return;
  }

  const { rowIndex, colIndex } = index;

  const indexString = convertIndexToString(rowIndex, colIndex);
  const isSameAsPrevIndex = indexString === currentIndex.current;

  if (isSameAsPrevIndex) {
    return;
  }

  currentIndex.current = indexString;

  const [startRowIndex, startColIndex] = convertStringToIndex(startIndex.current);
  const [minRow, maxRow] = [startRowIndex, rowIndex].sort((a, b) => a - b);
  const [minCol, maxCol] = [startColIndex, colIndex].sort((a, b) => a - b);

  const newValue = [...value];
  newValue.forEach((r, i) => {
    r.forEach((_, j) => {
      if (i < minRow || i > maxRow || j < minCol || j > maxCol) {
        newValue[i][j] = startTable.current[i][j];
      } else {
        newValue[i][j] = mode.current;
      }
    });
  });

  setValue(newValue);
};

드래그 중일 때 실행되는 함수입니다. 위 함수를 요약하면 다음과 같습니다.

  1. startIndex 가 비어있다면 종료합니다.
  2. 마우스 이벤트일 때, 마우스 버튼의 종류가 Primary button 이 아니면 종료합니다.
  3. 이벤트로부터 가져온 인덱스가 유효하지 않다면 종료합니다.
  4. 이벤트로부터 가져온 인덱스가 currentIndex 와 같다면 종료합니다.
  5. currentIndex 를 업데이트합니다.
  6. startIndex 와 현재의 인덱스 사이의 모든 값을 mode 로 설정합니다.
  7. startIndex 와 현재의 인덱스 범위 밖에 있는 모든 값은 startTable 의 맵핑되는 값으로 설정합니다.

7번은 드래그를 했다가 다시 되돌리는 동작을 할 때 원래의 값으로 초기화 하기 위함입니다.

handleDragEnd

const handleDragEnd = (e: Event) => {
  startIndex.current = '';
  currentIndex.current = '';
  startTable.current = [];
  mode.current = false;
  
  if (e.cancelable) {
    e.preventDefault();
  }
}

handleDragEnd 는 간단합니다. ref 들을 초기화 해줍니다. 또 터치 이벤트일 때에는 앞서 설명했듯, touchstart -> touchend 이후 마우스 이벤트가 발생합니다. 따라서 e.preventDefult() 로 마우스 이벤트가 발생하는 기본 동작을 막아주었습니다.

이벤트 붙이기

...
    <Table
      onTouchStart={handleDragStart}
      onMouseDown={handleDragStart}
      onTouchMove={handleDragMove}
      onMouseOver={handleDragMove}
      onTouchEnd={handleDragEnd}
      onMouseUp={handleDragEnd}
    >
...

이렇게 만든 핸들러 함수들을 각 이벤트 처리기에 맞게 붙여 드래그 테이블을 완성했습니다.


구현 팁

테이블 Row 인덱스 가져오기

const tr = target.parentNode;
rowIndex = tr.sectionRowIndex;

trrowIndex 를 가져오기 위해서 보틍 tr.rowIndex 를 사용합니다만, 이렇게 되면 table 전체를 기준으로 인덱스를 가져옵니다. 만약 theadtr 가 있다면 thead 의 row도 포함됩니다.

sectionRowIndex 를 사용하면 현재 섹션을 기준으로 rowIndex 를 가져옵니다. tbody 가 있다면 여기를 기준으로 가져옵니다.

테이블 Column 인덱스 가져오기

const tds = tr.querySelectorAll('td');

for (let i = 0; i < tds.length; i++) {
  if (tds[i] === target) {
    colIndex = i;
    break;
  }
}

element.cellIndex 에서 cellIndexth 도 포함합니다. th 를 포함하고 싶지 않다면 위처럼 직접 tds 를 구해야 합니다.

type guard 만들기

function isMouseEvent(event: Event): event is MouseEvent {
  return event instanceof MouseEvent;
}

function isTouchEvent(event: Event): event is TouchEvent {
  return 'ontouchstart' in window && event.type.startsWith('touch');
}


리팩토링

이대로도 충분히 괜찮지만 몇 가지 아쉬운 점들이 있습니다. 마지막으로 리팩토링 과정을 설명드리겠습니다.

문제 인식하기

function DraggableTable(initialValue) {  
  const startIndex = useRef<string>('');
  const currentIndex = useRef<string>('');
  const startTable = useRef<boolean[][]>([]);
  const mode = useRef<boolean>(false);
                                       
  const [value, setValue] = useState<boolean[][]>(initialValue);
  
  // ... 생략

  return (
    <Table
      onTouchStart={handleDragStart}
      onMouseDown={handleDragStart}
      onTouchMove={handleDragMove}
      onMouseOver={handleDragMove}
      onTouchEnd={handleDragEnd}
      onMouseUp={handleDragEnd}  
    >
      <THead>
        {/* ...생략 */}
      </THead>
      <TBody>
        {value.map((row, rowIndex) => (
          <TR key={rowIndex}>
              <TH>
                {time}
              </TH>
            {row.map(() => (
              <TD></TD>
            ))}
          </TR>
        ))}
      </TBody>
    </Table>
  );
}

위 코드의 생략된 부분 중 많은 부분은 드래그 테이블과는 관련이 없습니다. 즉, 테이블을 드래그하는 로직을 분리하는 것이 코드 유지보수에 유리할 것입니다. 또 현재는 드래그 테이블 로직이 다른 곳에서 사용될 수 없습니다. 이 문제를 hook 을 만들어 해결했습니다.


Hook 만들기


export function useTableDragSelect(
  initialTable?: boolean[][],
): [React.RefObject<HTMLTableElement>, boolean[][]] {
  const startIndex = useRef<string>('');
  const currentIndex = useRef<string>('');
  const startTable = useRef<boolean[][]>([]);
  const mode = useRef<boolean>(false);

  const tableRef = useRef<HTMLTableElement>(null);
  const [tableValue, setTableValue] = useState<boolean[][]>(initialTable ?? []);

  // ... handler 함수 생략

  useEffect(() => {
    const node = tableRef.current?.querySelector('tbody') ?? tableRef.current;

    setTableValue(initialTable ?? []);

    if (!initialTable && node) {
      const trs = node.querySelectorAll('tr');
      const newTableValues: boolean[][] = [];
      trs.forEach((tr, i) => {
        const tds = tr.querySelectorAll('td');
        const row: boolean[] = [];
        tds.forEach((td, j) => {
          row.push(false);
        });

        if (tds.length > 0) {
          newTableValues.push(row);
        }
      });

      setTableValue(newTableValues);
    }
  }, [initialTable]);

  useEffect(() => {
    const node = tableRef.current?.querySelector('tbody') ?? tableRef.current;

    if (node) {
      node.addEventListener('touchstart', handlePointerStart);
      node.addEventListener('mousedown', handlePointerStart);
      node.addEventListener('touchmove', handlePointerMove);
      node.addEventListener('mouseover', handlePointerMove);
      node.addEventListener('touchend', handlePointerEnd);
      node.addEventListener('mouseup', handlePointerEnd);
      return () => {
        node.removeEventListener('touchstart', handlePointerStart);
        node.removeEventListener('mousedown', handlePointerStart);
        node.removeEventListener('touchmove', handlePointerMove);
        node.removeEventListener('mouseover', handlePointerMove);
        node.removeEventListener('touchend', handlePointerEnd);
        node.removeEventListener('mouseup', handlePointerEnd);
      };
    }
  }, [handlePointerStart, handlePointerMove, handlePointerEnd]);

  return [tableRef, tableValue];
}

refvalue 를 리턴하는 훅을 만들었고 기존 테이블에 있던 드래그 로직을 전부 훅으로 옮겼습니다. 추가적으로 initialTable 을 옵션으로 받을 수 있도록 해서 만약 initialTable 을 받지 않았다면 직접 테이블을 파싱하여 value 이차원 배열을 만들도록 하였습니다.

이제 모든 컴포넌트에서 이 훅을 가져와 기존 테이블의 기능을 확장하여 사용할 수 있습니다.

function MyTable() {
  const initialTable = Array.from(Array(5), () =>
    new Array(5).fill(false),
  );
  
  const [tableRef, tableValue] = useTableDragSelect(initialTable)

  return (
    <Table ref={tableRef}>
      <THead>
        {/* ...생략 */}
      </THead>
      <TBody>
        {tableValue.map((row, rowIndex) => (
          <TR key={rowIndex}>
              <TH>
                {time}
              </TH>
            {row.map(() => (
              <TD></TD>
            ))}
          </TR>
        ))}
      </TBody>
    </Table>
  );
}

initialTable 이 없다면 아래처럼 테이블이 완성되어 있어야 합니다.

function MyTable() {  
  const [tableRef, tableValue] = useTableDragSelect()
  
  console.log(tableValeu) // 3*5 size Array

  return (
    <Table ref={tableRef}>
      <THead>
        {/* ...생략 */}
      </THead>
      <TBody>
        <TR>
          <TH></TH>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
        </TR>
        <TR>
          <TH></TH>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
        </TR>
        <TR>
          <TH></TH>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
          <TD></TD>
        </TR>
      </TBody>
    </Table>
  );
}


결론

위에서 만든 useTableDragSelect 이라는 훅을 배포했고 아래의 링크에서 확인해볼 수 있습니다.

Github: jeonbyeongmin/use-table-drag-select



참고

터치와 클릭, 우리 깐부잖아 - TOAST UI
MouseEvent - MDN
TouchEvent - MDN
Pointer events - javascript.info

profile
JavaScript/React 개발자

0개의 댓글