Frontend

[React.js] 테이블 드래그 기능

J1Eun 2023. 11. 3. 14:48

테이블에 드래그 시 드래그 한 범위가 선택/선택 취소되는 기능 개발을 해야한다..

아주 적절한 블로그를 찾아서 가져다가 쓰려고 했지만.. 배포된 기능으로는 커스텀이 쉽지 않아서 해당 블로그 내용을 기반으로 커스텀 개발을 진행했다.

 

드래그 테이블 참고

 

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

use-table-drag-select시간표와 관련된 프로젝트에서 <table /> 요소를 마우스로 드래그해서 선택해야 하는 기능이 필요했습니다. 기능 요구 사항은 다음과 같습니다.테이블이 N\*M 크기라면 value 는 N\*M

velog.io


필요한 변수 선언

const DragTable = ({ initValue, setInitValue }) => {
    const tableRef = useRef(null);
    const startIndex = useRef(''); // 시작 인덱스
    const currentIndex = useRef('');
    const mode = useRef(false); // 드래그 모드
    const [value, setValue] = useState(new Set());
    
    ---[생략]---
}

 

  • initValue : new Set() 으로 선언된 값, DragTable을 생성할 때 선택된 셀의 id값을 저장하기 위한 변수
  • setInitValue : initValue 변경 메소드 => 테이블의 선택되는 값은 DragTable 밖에서 핸들링
  • tableRef : 드래그 테이블의 ref로 지정
  • startIndex : 드래그 시작 id 값
  • currentIndex : 현재 드래그 위치 id 값
  • mode : 최초 드래그 위치의 선택 여부 (true : 선택, false : 미선택) 
  • value : 드래그 대상 셀 id 값

마우스/터치 이벤트 여부 확인 메소드

// 마우스 이벤트 여부
function isMouseEvent(event) {
    return event instanceof MouseEvent;
}

// 터치 이벤트 여부
function isTouchEvent(event) {
    return 'ontouchstart' in window && event.type.startsWith('touch');
}

기타 메소드

// row, col index 가져오기
const convertStringToIndex = (index) => {
    let indexArr = index.split('-');
    return [parseInt(indexArr[0]), parseInt(indexArr[1])];
};

// 셀 id 가져오기
const getCellId = (e) => {
    if (e.target.tagName == 'TD') {
        return e.target.id;
    } else {
        return e.target.closest('td').id;
    }
};

드래그 시작

// 드래그 시작
const handleDragStart = (e) => {
    const index = getCellId(e);

    // 가져온 인덱스가 유효하지 않은 경우 함수 종료
    if (index === null) {
        return;
    }

    // 마우스 이벤트 일 때 : 좌 클릭이 아니면 종료
    if (isMouseEvent(e) && e.buttons !== 1) {
        return;
    }

    // 터치 이벤트 일 때 : 스크롤 비활성화
    if (isTouchEvent(e) && e.cancelable) {
        e.preventDefault();
    }
    
    // 시작 컬럼 id 저장
    startIndex.current = index;
    
    // 시작 컬럼이 선택 되어 있으면 미선택, 미선택이면 선택 처리
    mode.current = !value.has(index);
    
    // mode에 따라 드래그 처리
    let copy_value = _.cloneDeep(value);
    if (!mode.current) {
        copy_value.delete(index);
    } else {
        copy_value.add(index);
    }

    dragNoData(copy_value);
};

 

드래그 중

// 드래그 중
const handleDragMove = (e) => {
    if (startIndex.current === '') {
        return;
    }

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

    const index = getCellId(e);

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

    const isSameAsPrevIndex = index === currentIndex.current;

    if (isSameAsPrevIndex) {
        return;
    }

    currentIndex.current = index;

    const [startRowIndex, startColIndex] = convertStringToIndex(startIndex.current);
    const [rowIndex, colIndex] = convertStringToIndex(index);

    const [minRow, maxRow] = [startRowIndex, rowIndex].sort((a, b) => a - b);
    const [minCol, maxCol] = [startColIndex, colIndex].sort((a, b) => a - b);
    
    // 드래그 된 시작 -> 현재 컬럼까지 셀 선택/미선택 처리
    let copy_value = _.cloneDeep(value);
    for (let row = minRow; row <= maxRow; row++) {
        for (let col = minCol; col <= maxCol; col++) {
            let idx = `${row}-${String(col).padStart(2, '0')}`;

            if (!mode.current) {
                copy_value.delete(idx);
            } else {
                copy_value.add(idx);
            }
        }
    }

    dragNoData(copy_value);	// 드래그 시 실시간으로 처리
};

드래그 종료

// 드래그 종료
const handleDragEnd = (e) => {
    startIndex.current = '';
    currentIndex.current = '';
    mode.current = false;
    
    // 드래그 결과를 실시간으로 반영하지 않고 마우스를 뗐을 때 반영하고 싶다면 이곳에서 dragNoData() 처리

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

테이블 드래그 처리

// 프로젝트 드래깅
const dragNoData = (dragData) => {
    let data = new Set();

    Array.from(dragData).map((d) => {
        if (d !== '') {
            let element = document.getElementById(d);
            let elementName = element != null ? element.getAttribute('name') : '';

            if (elementName == 'yesData' || elementName == 'noData') data.add(d);
        }
    });

    setInitValue(data);
};

 

드래그 시 name tag에 'yesData' 와 'noData' 인 컬럼만 선택 가능해야하기 때문에 조건을 따로 줌.

(yesData : 데이터가 이미 있을 때, noData : 미입력 부분, 그 외는 선택 불가능)

useEffect

// 선택 셀 초기화
useEffect(() => {
    setValue(initValue || new Set());
}, [initValue]);

// table에 리스너 추가
useEffect(() => {
    const node = tableRef.current?.querySelector('tbody') ?? tableRef.current;

    if (node) {
        node.addEventListener('touchstart', handleDragStart);
        node.addEventListener('mousedown', handleDragStart);
        node.addEventListener('touchmove', handleDragMove);
        node.addEventListener('mouseover', handleDragMove);
        node.addEventListener('touchend', handleDragEnd);
        node.addEventListener('mouseup', handleDragEnd);
        return () => {
            node.removeEventListener('touchstart', handleDragStart);
            node.removeEventListener('mousedown', handleDragStart);
            node.removeEventListener('touchmove', handleDragMove);
            node.removeEventListener('mouseover', handleDragMove);
            node.removeEventListener('touchend', handleDragEnd);
            node.removeEventListener('mouseup', handleDragEnd);
        };
    }
}, [handleDragStart, handleDragMove, handleDragEnd]);

Table Code

<Table ref={tableRef}>
    <TableHead>
        ---
    </TableHead>
    <TableBody>
        --- 
            // 코드 상에서는 DragTable 불러오는 부분에서 그려줌
            <TableCell
                name="noData"
                id={keyValue}
                // id 값이 initValue 에 있으면 css 처리
                className={initValue.has(keyValue) ? 'selectNoData' : ''}
            >
                미입력
            </TableCell>
        ---
    </TableBody>
</Table>

 

시연

테이블 드래그 시연