프로젝트 기록

[Meetable] Draggable-time-selector 구현기 (with REACT, TS)

lerrybe 2023. 8. 1. 17:22


Issue 

 

띠링

 

현재 참여 중인 프로젝트에서 드래그로 슬롯들을 선택해야하는 부분이 있었습니다.

 

일반적으로 선택하고 싶은 영역을 클릭으로 선택하는 간단한 경우와 다르게 드래그 행동을 통해 원하는 영역을 선택하는 것이기 때문에 선택과 관련된 여러 이벤트(click, mouseUp, mouseDown 등) 처리를 잘 해줘야하는 컴포넌트라고 느꼈는데요,

 

특히 마우스 up이 되었을 때만 실제 데이터가 저장되어야 하는데 드래그 하는 와중에는 이리저리 왔다갔다 할 수도 있기 때문에..

선택 가능성이 있는 슬롯들(mouse up이 되기 이전에 임시로 선택된 슬롯들)을 어떻게 관리하면 좋을지 고민해봤습니다! 

 

📑 Brief specification & Basic actions

사용자가 기본적으로 할 수 있는 행위는 무엇일까? 

슬롯들을 드래그하면 해당하는 슬롯들이 선택되고, 선택된 슬롯들을 다시 드래그하면 선택이 해제됩니다.

 

👾 과정 1

Q. 현재 마우스는 어떤 슬롯 위에 있는가? 

 

현재 마우스 포인터가 어떤 슬롯 위에 있는지 먼저 알고 싶어서 mouseEnter 이벤트를 각각 셀에 걸어줬습니다.

마우스 포인터가 각각 슬롯에 닿을 때 행동이 일어나는 게 더 적합할 것 같았고, 각각 슬롯이 가장 작은 단위이기도 했습니다.

또한 mouseMove와는 다르게 이벤트 발생시 버블링이 일어나지 않아 딱 자기자신만이 이벤트를 받을 수 있게 되기 때문에 사용했습니다. 

 

그래서 isDragging 값을 줘서 mouseDown 상태 ~ mouseUp 상태 사이에 있을 때 마우스가 이동한 슬롯들이 색칠되게 하려고 했는데,

const handleMouseEnter = useCallback((endedTimeSlot: TimeSlot) => {
  // 슬롯 색칠
}, []);

// ...

<Cell onMouseEnter={() => { handleMouseEnter(timeSlots[colIndex]) }} />

 

마우스가 빠른 속도로 이동하게 되면 (화면이 갱신되는 속도가 빠르면) 브라우저는 모든 마우스 위치를 정확히 추적할 수 없어 다음과 같이 끊김 현상이 일어났습니다.

 

드르륵

 


👾 과정 2

Q. 그렇다면 직선을 그을 수는 없나? 

 

dotting issue에서 관련된 이슈가 있었던 것 같아 찾아보니, 시작점과 끝점의 기울기를 기준으로 같은 직선에 있는 셀들을 예측하여 칠해줄 수 있는 브레젠험 직선 알고리즘이 있었습니다. (브레젠험 직선 알고리즘)

 

https://velog.io/@octo__/%EB%B8%8C%EB%A0%88%EC%A0%A0%ED%97%98-%EC%A7%81%EC%84%A0-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98Bresenhams-line-algorithm
https://github.com/hunkim98/dotting/issues/36

검정색은 mouseDown으로 인해 색칠된 픽셀들, 회색은 색칠되었어야 했는데 색칠되지 못한 픽셀들입니다.

간단히 말하면 색칠된 픽셀들 중 startPoint - endPoint를 주면 직선의 방정식이 나오고, 해당 위치에 있는 픽셀들을 색칠해 업데이트해주면 됩니다.

 

자세한 내용은 아래 이슈와 블로그에 친절하게 나와있어서, 참고하면 좋을 것 같습니다.

bolee.log

Improve BrushToo.DOT drawing method

 

현황


👾 과정 3

Q. '여기서부터 ~ 여기까지 드래그해서 선택'의 느낌이 부족하다. 

 

여기까지 진행했는데, 막상 'selector'라는 이름을 붙여 사용하려니 좀 이상하더군요..!

가로와 세로를 선택할 때는 큰 문제를 느끼지 못했는데, 대각을 선택할 때 대각을 그리는 의도랑 칠해지는 영역이랑 다른 것 같았습니다. 

 

그림판처럼 마우스가 지나온 자리만 선택할 수 있게 쓰려는 의도는 아닌 것 같고, 실제 마우스 드래그 할 때처럼 범위 선택이 가능하는게 더 목적에 맞고 사용성에 좋을 것 같아 수정하게 되었습니다. 대각 가로 세로에 해당하는 모든 슬롯들을 선택하고 싶을 것 같으니, 사용자 행동을 다시 정의합니다.

 

슬롯들을 드래그하면 해당하는 슬롯들이 선택되고, 선택된 슬롯들을 다시 드래그하면 선택이 해제된다.
+ 🚀 대각으로 드래그하면 대각에 시작지점과 종료지점에 해당하는 모든 행과 열이 사각 모양으로 선택된다.

 

https://www.when2meet.com/ 일정 조정으로 자주 쓰이는 when2meet의 동작 방식을 참고하였습니다.


주요 구현 내용

Dependency 

스타일링을 위해 styled-components, 날짜 변환을 위해 moment(후에 dayjs로 교체했습니다.)를 사용했습니다.

 

 

✅ 타입 소개

 

기본적으로 프로젝트에서 날짜는 Date[]로 관리, slot 하나는 TimeSlot 타입으로 관리하고 있습니다.

export interface TimeSlot {
  date: string;
  startTime: string;
  endTime: string;
}

 

각 슬롯을 선택함에 있어 관련된 속성들은 dragEventStates로 관리합니다.

export enum Selection {
  ADD = "add",
  REMOVE = "remove",
}

export interface DragEventStates {
  selectionType: Selection | null;
  startedTimeSlot: TimeSlot | null;
  cachedSelectedTimeSlots: TimeSlot[];
}

드래그가 진행 중일 때는 선택된 영역을 반영하고 싶지 않았습니다. 사용자가 드래그 진행하면서 선택 영역이 계속 변하기 때문에, mouseUp 이벤트가 발생했을 때만 선택된 TimeSlot들을 반영해주고 싶어서 임시로 저장해줄 수 있는 캐시 배열을 사용했습니다. 또한 지금은 선택 즉시 반영되지만 후에 드래그가 끝났을 때 필요한 경우 사용자에게 확인을 받거나 다른 절차가 있을 수 있어서 현재 드래그 중인 데이터들과 최종적으로 선택이 되어있는 데이터들을 분리해주기로 했습니다.

 

따라서 cachedSelectedTimeSlots는 드래그 이벤트를 처리하는 과정에서 선택된 시간 슬롯들을 임시로 저장하는 용도이고, 선택된 시간 슬롯들을 실제로 반영하기 전에, 드래그 동작이 끝날 때까지 적용할 변경 사항들을 저장하는데 사용됩니다.

 

 

 

 

TimeSlots 컴포넌트

 

다음은 아래 사진과 같이 선택 영역들(빨간 네모)을 렌더해주는 TimeSlots 컴포넌트인데, matrix를 인자로 받아 테이블처럼 보여줍니다. 

너무 길어질까봐 아래 더보기에 숨겨두었습니다. 🔽

 

더보기
더보기
import * as S from "./styles";
import { type TimeSlot } from "../../../types/time";
import { areTimeSlotsEqual } from "../../../utils/time";

interface TimeSlotsProps {
  timeSlotMatrix?: TimeSlot[][];
  cachedSelectedTimeSlots?: TimeSlot[];
  handleMouseUp: (timeSlot: TimeSlot) => void;
  handleMouseDown: (timeSlot: TimeSlot) => void;
  handleMouseEnter: (timeSlot: TimeSlot) => void;
}

const TimeSlots = ({
  handleMouseUp,
  handleMouseDown,
  handleMouseEnter,
  timeSlotMatrix,
  cachedSelectedTimeSlots,
}: TimeSlotsProps) => {
  if (!timeSlotMatrix) {
    return <></>;
  }

  const cols: number = timeSlotMatrix?.length;
  const rows: number = timeSlotMatrix[0]?.length;

  const gridTemplateRows: string = `repeat(${rows}, 30px)`;
  const gridTemplateColumns: string = `repeat(${cols}, 60px)`;

  return (
    <ul style={{ display: "grid", gridTemplateColumns, gridTemplateRows }}>
      {timeSlotMatrix[0]?.map((_, colIndex: number) =>
        timeSlotMatrix?.map((timeSlots, rowIndex: number) => {
          const selected = Boolean(
            cachedSelectedTimeSlots?.find((slot) =>
              areTimeSlotsEqual(slot, timeSlots[colIndex])
            )
          );
          return (
            <S.Slot
              selected={selected}
              key={`${rowIndex}-${colIndex}`}
              onMouseUp={() => {
                handleMouseUp(timeSlots[colIndex]);
              }}
              onMouseDown={() => {
                handleMouseDown(timeSlots[colIndex]);
              }}
              onMouseEnter={() => {
                handleMouseEnter(timeSlots[colIndex]);
              }}
            ></S.Slot>
          );
        })
      )}
    </ul>
  );
};

export default TimeSlots;

TimeSlots

 

 

 

 

🧨 Utils.ts

 

다음은 조건에 맞는 데이터를 set해주거나 get, 혹은 계산하는 util 함수들입니다.

 

  1. areTimeSlotsEqual: 두 개의 시간 슬롯을 비교하여 그들이 동일한지 확인
  2. isDateBetween: target이 되는 특정 날짜가 시작 날짜와 종료 날짜 사이에 속하는지 여부를 확인
  3. isTimeBetween: target이 되는 특정 시간이 시작 시간과 종료 시간 사이에 속하는지 여부를 확인
  4. getTimeSlotRecord: 주어진 날짜들과 시간 간격에 따라 시간 슬롯들을 Record 타입으로 생성 (후에 map으로 비교하면 시간을 줄일 수 있을 것 같아서 작성, 수정 예정) 
  5. getTimeSlotMatrix: 4번 결과로 나온 시간 슬롯 레코드를 기반으로 날짜별 시간 슬롯들을 행렬 형태로 구성
  6. updateCachedSelectedTimeSlots: 드래그 이벤트에 따라 선택된 시간 슬롯들을 업데이트, 드래그가 시작된 슬롯과 종료된 슬롯을 비교하여 그 사이에 있는 시간 슬롯들을 선택 또는 해제

updateCachedSelectedTimeSlots 에 대해 좀 더 설명하자면, 주어진 드래그 이벤트 상태를 기반으로 선택된 시간 슬롯들을 업데이트하고, 업데이트된 선택된 시간 슬롯들과 기존의 슬롯을 비교해 캐시된 배열 상태로 업데이트해줍니다.

 

✅ startedTimeSlot, selectionType이 없는 경우, 즉 드래그 이벤트가 시작되지 않았거나 선택 유형이 지정되지 않은 경우 함수가 종료된다.
✅ 드래그 이벤트가 시작된 경우, startedTimeSlot과 endedTimeSlot을 이용하여 선택된 시간 슬롯들을 계산한다.
✅ selectionType에 따라서 다음과 같은 동작을 수행한다.
    Selection.ADD: 기존의 selectedTimeSlots 배열과 새롭게 계산된 선택된 시간 슬롯들을 합친다.
    Selection.REMOVE: selectedTimeSlots에서 새롭게 계산된 선택된 시간 슬롯들을 제거한다.
    기타 경우: 현재의 selectedTimeSlots 배열을 유지한다.
✅ 업데이트된 선택된 시간 슬롯들을 cachedSelectedTimeSlots에 반영한다. 

 

 

결과적으로 updateCachedSelectedTimeSlots 함수를 사용하여 드래그 이벤트에 따라 선택된 시간 슬롯들을 쉽게 업데이트할 수 있고, 함수는 React 컴포넌트에서 드래그 이벤트 핸들러 등과 함께 사용되어, 시간 슬롯 선택과 관련된 로직을 처리하는 데 활용됩니다.

 

 

import moment from "moment";
import { type DragEventStates, Selection } from "../types/event";
import { type TimeSlot, type TimeSlotRecord } from "../types/time";

export const areTimeSlotsEqual = (a: TimeSlot, b: TimeSlot) => {
  return (
    a.date === b.date && a.startTime === b.startTime && a.endTime === b.endTime
  );
};

function isDateBetween(
  start: TimeSlot,
  target: TimeSlot,
  end: TimeSlot
): boolean {
  const endDate = moment(end.date);
  const startDate = moment(start.date);
  const targetDate = moment(target.date);
  return targetDate.isBetween(startDate, endDate, "day", "[]");
}

function isTimeBetween(
  start: TimeSlot,
  target: TimeSlot,
  end: TimeSlot
): boolean {
  const endStartTime = moment(end.startTime, "HH:mm");
  const startStartTime = moment(start.startTime, "HH:mm");
  const targetStartTime = moment(target.startTime, "HH:mm");
  return (
    targetStartTime.isSameOrAfter(startStartTime) &&
    targetStartTime.isSameOrBefore(endStartTime)
  );
}

export const getTimeSlotRecord = ({
  dates,
  timeUnit,
  endTime,
  startTime,
}: {
  dates?: Date[];
  startTime?: string | null;
  endTime?: string | null;
  timeUnit: 5 | 10 | 15 | 20 | 30 | 60;
}) => {
  if (!dates || !startTime || !endTime) return;

  const startHour = Number(startTime.split(":")[0]);
  const startMinute = Number(startTime.split(":")[1]);
  const endHour = Number(endTime.split(":")[0]);
  const endMinute = Number(endTime.split(":")[1]);

  const record: TimeSlotRecord = {};
  dates.forEach((date) => {
    const times: Record<string, TimeSlot> = {};
    const key = moment(date).format("YYYYMMDD");
    let hour = startHour;
    let minute = startMinute;
    while (hour < endHour || (hour === endHour && minute < endMinute)) {
      const formattedHour = hour.toString().padStart(2, "0");
      const formattedMinute = minute.toString().padStart(2, "0");
      const currEndMinute = minute + timeUnit;

      let formattedEndHour = hour.toString().padStart(2, "0");
      let formattedEndMinute = currEndMinute.toString().padStart(2, "0");

      if (currEndMinute >= 60) {
        formattedEndHour = (hour + 1).toString().padStart(2, "0");
        formattedEndMinute = (currEndMinute - 60).toString().padStart(2, "0");
      }

      times[`${formattedHour}:${formattedMinute}`] = {
        date: key,
        startTime: `${formattedHour}:${formattedMinute}`,
        endTime: `${formattedEndHour}:${formattedEndMinute}`,
      };

      minute += timeUnit;
      if (minute >= 60) {
        hour += 1;
        minute -= 60;
      }
    }
    record[key] = times;
  });
  return record;
};

export const getTimeSlotMatrix = (timeSlotRecord: TimeSlotRecord) => {
  const matrix: TimeSlot[][] = [];
  for (const date in timeSlotRecord) {
    const timeSlots = [];
    for (const time in timeSlotRecord[date]) {
      timeSlots.push(timeSlotRecord[date][time]);
    }
    matrix.push(timeSlots);
  }
  return matrix;
};

export const updateCachedSelectedTimeSlots = ({
  endedTimeSlot,
  timeSlotMatrix,
  dragEventStates,
  selectedTimeSlots,
  setDragEventStates,
}: {
  timeSlotMatrix: TimeSlot[][];
  selectedTimeSlots: TimeSlot[];
  endedTimeSlot: TimeSlot | null;
  dragEventStates: DragEventStates;
  setDragEventStates: React.Dispatch<React.SetStateAction<DragEventStates>>;
}) => {
  const { startedTimeSlot, selectionType, cachedSelectedTimeSlots } =
    dragEventStates;

  if (!startedTimeSlot || !selectionType) return;

  const updatedCachedSelectedTimeSlots: TimeSlot[] =
    startedTimeSlot && endedTimeSlot && selectionType
      ? endedTimeSlot
        ? timeSlotMatrix.reduce((acc, dayOfTimes) => {
            const dateIsReversed = moment(endedTimeSlot.date).isBefore(
              moment(startedTimeSlot.date)
            );
            const timeIsReversed = moment(
              endedTimeSlot.startTime,
              "HH:mm"
            ).isBefore(moment(startedTimeSlot.startTime, "HH:mm"));
            return acc.concat(
              dayOfTimes.filter(
                (t) =>
                  isDateBetween(
                    dateIsReversed ? endedTimeSlot : startedTimeSlot,
                    t,
                    dateIsReversed ? startedTimeSlot : endedTimeSlot
                  ) &&
                  isTimeBetween(
                    timeIsReversed ? endedTimeSlot : startedTimeSlot,
                    t,
                    timeIsReversed ? startedTimeSlot : endedTimeSlot
                  )
              )
            );
          }, [])
        : [startedTimeSlot]
      : [];

  const nextDraft =
    selectionType === Selection.ADD
      ? Array.from(
          new Set([...selectedTimeSlots, ...updatedCachedSelectedTimeSlots])
        )
      : selectionType === Selection.REMOVE
      ? selectedTimeSlots.filter(
          (a) =>
            !updatedCachedSelectedTimeSlots.find((b) => areTimeSlotsEqual(a, b))
        )
      : [...selectedTimeSlots];

  setDragEventStates((prev) => ({
    ...prev,
    cachedSelectedTimeSlots: nextDraft,
  }));
};

 

 

 

 DraggableSelector

다음은 컴포넌트를 호출하는 최종 DraggableSelector 부분입니다. 아래 더보기 🔽 

 

더보기
더보기
import * as S from "./styles";
import React, { useEffect, useState } from "react";

import {
  type Time,
  type TimeSlot,
  // type TimeSlotRecord,
} from "../../types/time";
import { type DragEventStates, Selection } from "../../types/event";

import {
  areTimeSlotsEqual,
  getTimeSlotMatrix,
  getTimeSlotRecord,
  updateCachedSelectedTimeSlots,
} from "../../utils/time";

import TimeLabel from "./TimeLabel";
import DateLabel from "./DateLabel";
import TimeSlots from "./TimeSlots";

interface DraggableSelectorProps {
  selectedDates: Date[];
  selectedTime: Time | null;
  selectedTimeSlots: TimeSlot[];
  setSelectedTimeSlots: React.Dispatch<React.SetStateAction<TimeSlot[]>>;
}

export default function DraggableSelector({
  selectedTime,
  selectedDates,
  selectedTimeSlots,
  setSelectedTimeSlots,
}: DraggableSelectorProps) {

  /* STATES */
  const [dragEventStates, setDragEventStates] = useState<DragEventStates>({
    selectionType: null,
    startedTimeSlot: null,
    cachedSelectedTimeSlots: [...selectedTimeSlots],
  });

  const [timeSlotMatrix, setTimeSlotMatrix] = useState<TimeSlot[][]>([]);
  // TODO: manage data with Record
  // const [timeSlotRecord, setTimeSlotRecord] = useState<TimeSlotRecord>();

  /* ACTIONS */
  const startSelection = (
    startedTimeSlot: TimeSlot,
    selectedTimeSlots: TimeSlot[]
  ) => {
    const selectedTimeSlot = selectedTimeSlots.find((slot) =>
      areTimeSlotsEqual(startedTimeSlot, slot)
    );
    setDragEventStates((prev) => ({
      ...prev,
      startedTimeSlot: startedTimeSlot,
      selectionType: selectedTimeSlot ? Selection.REMOVE : Selection.ADD,
    }));
  };
  const updateData = () => {
    setSelectedTimeSlots(dragEventStates.cachedSelectedTimeSlots);
    setDragEventStates((prev) => ({
      ...prev,
      selectionType: null,
      startedTimeSlot: null,
    }));
  };
  const updateCache = (endedTimeSlot: TimeSlot) => {
    updateCachedSelectedTimeSlots({
      endedTimeSlot,
      timeSlotMatrix,
      dragEventStates,
      selectedTimeSlots,
      setDragEventStates,
    });
  };

  /* HANDLERS */
  const handleMouseUp = (endedTimeSlot: TimeSlot) => {
    updateCache(endedTimeSlot);
  };
  const handleMouseEnter = (endedTimeSlot: TimeSlot) => {
    updateCache(endedTimeSlot);
  };
  const handleMouseDown = (startedTimeSlot: TimeSlot) => {
    startSelection(startedTimeSlot, selectedTimeSlots);
  };

  /* EFFECTS */
  useEffect(() => {
    document.addEventListener("mouseup", updateData);
    return () => {
      document.removeEventListener("mouseup", updateData);
    };
  }, [updateData]);

  useEffect(() => {
    const record = getTimeSlotRecord({
      timeUnit: 30,
      dates: selectedDates,
      startTime: selectedTime?.startTime,
      endTime: selectedTime?.endTime,
    });
    if (record) {
      // setTimeSlotRecord(record);
      setTimeSlotMatrix(getTimeSlotMatrix(record));
    }
  }, [selectedDates, selectedTime?.startTime, selectedTime?.endTime]);

  return (
    <S.Wrapper>
      <div style={{ display: "flex" }}>
        {selectedDates && selectedTime?.startTime && selectedTime?.endTime && (
          <div>
            <S.Label>시간</S.Label>
            <TimeLabel timeSlots={timeSlotMatrix[0]} />
          </div>
        )}
        <div>
          {selectedDates &&
            selectedTime?.startTime &&
            selectedTime?.endTime && <DateLabel dates={selectedDates} />}
          <TimeSlots
            timeSlotMatrix={timeSlotMatrix}
            handleMouseUp={handleMouseUp}
            handleMouseDown={handleMouseDown}
            handleMouseEnter={handleMouseEnter}
            cachedSelectedTimeSlots={dragEventStates.cachedSelectedTimeSlots}
          />
        </div>
      </div>
    </S.Wrapper>
  );
}

 

 

이렇게 구현 과정과 내용을 간략하게 살펴보았습니다. 

이상이다.


 

+ live example 보러가기

 

react-draggable-selector

 

react-draggable-selector.vercel.app