본문 바로가기
Projects/OpenRoadmaps

[OpenRoadmaps] 6. 로드맵 에디터 기능 구현

by DevJaewoo 2023. 1. 27.
반응형

Intro

백엔드 API 구현이 완료돼서 사용자가 로드맵을 만들고 업로드할 수 있는 에디터를 만들었다.

이 프로젝트를 하며 가장 구현하기 힘들었다.

 

소스코드는 Github에 업로드 되어있습니다.

 

GitHub - DevJaewoo/OpenRoadmaps

Contribute to DevJaewoo/OpenRoadmaps development by creating an account on GitHub.

github.com


UI 구성

편집기 UI는 아래와 같다.

UI 구성

 

  1. 로드맵 항목 리스트: 편집 영역의 로드맵 항목들을 확인하고 선택할 수 있다.
  2. 로드맵 편집 영역: 이 영역에서 로드맵을 만들 수 있다.
  3. 로드맵 편집 모드 선택: 아이콘을 선택해 편집 모드를 변경한다.

 

로드맵 편집 아이콘 클릭 이벤트를 수신하게 했고,

<div className="flex flex-row justify-center items-center rounded-b-lg px-1 py-2 text-2xl absolute z-30 text-white bg-blue-600">
  <RoadmapEditButton
    icon={<BsCursor />}
    onClick={() => updateEditMode(EditMode.Cursor)}
    highlight={editMode === EditMode.Cursor}
  />
  <RoadmapEditButton
    icon={<AiOutlinePlusSquare />}
    onClick={() => updateEditMode(EditMode.Add)}
    highlight={editMode === EditMode.Add}
  />
  <RoadmapEditButton
    icon={<MdOutlineMoving />}
    onClick={() => updateEditMode(EditMode.Connect)}
    highlight={editMode === EditMode.Connect}
  />
  <RoadmapEditButton
    icon={<AiFillDelete />}
    onClick={() => updateEditMode(EditMode.Delete)}
    highlight={editMode === EditMode.Delete}
    lastElement
  />
</div>

 

클릭한 아이콘 종류에 따라 state를 업데이트하게 했다.

const [editMode, setEditMode] = useState<TEditMode>(EditMode.Cursor);

const updateEditMode = (mode: TEditMode) => {
  switch (mode) {
    case EditMode.Cursor:
      setEditMode(EditMode.Cursor);
      break;
    case EditMode.Add:
      setEditMode(EditMode.Add);
      break;
    case EditMode.Connect:
      setConnectorHintId(undefined);
      setConnectorStatus(undefined);
      setEditMode(EditMode.Connect);
      break;
    case EditMode.Delete:
      setEditMode(EditMode.Delete);
      break;
  }
};

구현 기능

구현한 기능들은 다음과 같다.

 

  • 로드맵 항목 추가
  • 로드맵 항목 연결
  • 로드맵 항목 이름 편집
  • 로드맵 항목 드래그 (위치 변경)
  • 로드맵 항목 삭제
  • 로드맵 항목 정보 편집
  • 로드맵 업로드

로드맵 항목 추가

추가 버튼
두번째 + 버튼을 눌러 진입할 수 있다.

영역 내 원하는 위치에 로드맵 항목을 추가해주는 기능이다.

MouseMove 이벤트를 통해 현재 마우스 위치에 힌트를 표시해주고, 이를 클릭하면 해당 위치에 새 로드맵 항목을 만들어준다.

 

X, Y 좌표를 받아올 때 offsetX, offsetY로 받아오는데, 부모에서 이벤트를 수신하더라도 자식 컴포넌트 위에서 이벤트가 수신되면 값이 초기화되기 때문에 이벤트를 수신하기 위한 컴포넌트를 따로 만들어줬다.

<div>
  ...
  {editMode === EditMode.Add && (
    <RoadmapEditItemHint onSelect={onRoadmapAddHintSelect} />
  )}
</div>

 

또 마우스 위치에 힌트를 표시해야되는데, 이 힌트를 이벤트 수신용 컴포넌트 위에 표시하면 이전과 같이 offsetX, offsetY 좌표가 초기화된다. 때문에 이벤트 컴포넌트의 자식이 아닌 형제로 두고, 이벤트 컴포넌트의 z-index를 올려 정상적인 좌표를 받아오도록 설정했다.

 

그림으로 표현하자면 아래와 같다.

컴포넌트 구조

메인 컴포넌트에서 onSelect 콜백 함수를 받으며, 클릭 이벤트가 발생되면 좌표값과 함께 함수를 호출해준다.

함수가 호출되면 넘겨받은 좌표값을 바탕으로 새 로드맵 항목을 만들고, 리스트에 추가해서 다시 렌더링 한다.

const [roadmapItemList, setRoadmapItemList] =
    useState<RoadmapItem[]>(defaultValue);

 

const onRoadmapAddHintSelect = (x: number, y: number) => {
  switch (editMode) {
    case EditMode.Add: {
      const newRoadmapItem: RoadmapItem = {
        id: nextId,
        name: "Example",
        x,
        y,
        content: "",
        recommend: Recommend.RECOMMEND,
        isCleared: false,
        connectionType: null,
        parentId: null,
        referenceList: [],
      };
      setNextId((id) => id + 1);
      setRoadmapItemList([...roadmapItemList, newRoadmapItem]);
      break;
    }
  }
};

 

항목 추가 기능 동작 확인


로드맵 항목 연결

연결 버튼

로드맵 항목 사이를 화살표로 연결하는 기능이다.

화살표로 연결하기 위해 아래의 라이브러리를 사용했는데, 화살표 방향이나 위치 등의 버그가 많아 직접 수정했다.

https://github.com/tudatn/react-svg-connector

 

수정 결과물은 깃허브에 등록했다.https://github.com/DevJaewoo/react-svg-connector

 

GitHub - DevJaewoo/react-svg-connector: Connect components with svg connectors

Connect components with svg connectors. Contribute to DevJaewoo/react-svg-connector development by creating an account on GitHub.

github.com

 

이 라이브러리는 연결선을 만들기 위해 연결할 컴포넌트의 ref를 요구한다. ref에 들어갈 로드맵 항목이 동적으로 바뀌어야 하기 때문에 이들을 배열 형태로 저장하고, 로드맵 항목 렌더링 시 배열에 ref를 추가하며 삭제 시 제거하도록 했다.

const roadmapItemRefs = useRef<{
  [key: number]: RefObject<HTMLDivElement>;
}>({});

const addRef = (key: number) => {
  if (roadmapItemRefs.current[key] !== undefined) {
    return roadmapItemRefs.current[key];
  }
  const newRef = createRef<HTMLDivElement>();
  roadmapItemRefs.current[key] = newRef;
  return newRef;
};

const removeRef = (key: number) => {
  delete roadmapItemRefs.current[key];
};

 

이후 저장된 ref들을 통해 Connector를 연결시켜줬다.

roadmapItemList
  .filter((r) => r.parentId)
  .map((r) => {
    if (r.parentId === null) return null;

    const to = roadmapItemRefs.current[r.id].current;
    const from = roadmapItemRefs.current[r.parentId]?.current;

    if (!from || !to) return null;

    return (
      <Connector
        key={r.id}
        el1={from}
        el2={to}
        shape="narrow-s"
        direction={r.connectionType as ShapeDirection}
        roundCorner
        endArrow
        stem={5}
        className="bg-opacity-100 z-0"
        onClick={() => {
          if (editMode === EditMode.Delete) {
            r.connectionType = null;
            r.parentId = null;
          }
        }}
      />
    );
  })}

 

위에 설명한 내용은 연결된 결과를 보여주는 방법이고, 사용자가 직접 로드맵을 만들기 위해선 연결선을 추가도 할 수 있어야 한다. 연결선 추가 방법은 파워포인트의 선 추가 방식을 모방해 로드맵 항목 위에 마우스를 올리면 연결 가능한 위치에 힌트가 표시되고, 힌트에 가까이 가면 마우스가 힌트 위치에 고정되며, 클릭 시 연결 선의 출발 / 끝 지점이 정해지는 방식으로 구현했다.

 

우선 로드맵 항목에 원형으로 표시되어 MouseEnter, Leave, Click을 수신하는 힌트 컴포넌트를 만들었다. Enter 시 마우스의 위치가 아닌 자신의 위치를 전달하기 때문에 스냅 기능을 구현할 수 있다.

interface Props {
  id: number;
  refs: RefObject<HTMLDivElement> | undefined;
  onSelect: (id: number, x: number, y: number, position: TPosition) => void;
  onHintEnter: (id: number, x: number, y: number, position: TPosition) => void;
  onHintLeave: (id: number) => void;
  position?: TPosition;
}

const RoadmapConnectorHintItem: FC<{
  x: number | string;
  y: number | string;
  onClick: (x: number, y: number) => void;
  onEnter: (x: number, y: number) => void;
  onLeave: () => void;
}> = ({ x, y, onClick, onEnter, onLeave }) => {
  const hintRef = useRef<HTMLDivElement>(null);

  const getCoord = () => {
    const { x: currentX, y: currentY } = getCurrentPositionPixel(
      hintRef.current
    );
    return {
      x: currentX + (hintRef.current?.offsetWidth || 0) / 2,
      y: currentY + (hintRef.current?.offsetHeight || 0) / 2,
    };
  };

  const handleClick = () => {
    const { x: currentX, y: currentY } = getCoord();
    onClick(currentX, currentY);
  };

  const handleEnter = () => {
    const { x: currentX, y: currentY } = getCoord();
    onEnter(currentX, currentY);
  };

  return (
    <div
      ref={hintRef}
      className="w-3 h-3 rounded-full z-10 absolute -translate-x-1/2 -translate-y-1/2 bg-white border-2 border-gray-400"
      onMouseEnter={handleEnter}
      onMouseLeave={onLeave}
      onClick={handleClick}
      style={{ top: y, left: x }}
      role="button"
      aria-hidden
    >
      {}
    </div>
  );
};

 

힌트들을 로드맵 항목의 상하좌우에 배치했다.

힌트 컴포넌트 렌더링에 조건문이 있는데, 연결선 라이브러리에서 세로는 세로끼리, 가로는 가로끼리만 연결을 지원해서 잘못 연결되지 않도록 연결할 수 없는 위치엔 힌트를 표시하지 않도록 한것이다.

const RoadmapConnectorHint: FC<Props> = ({
  id,
  refs,
  onSelect,
  onHintEnter,
  onHintLeave,
  position,
}) => {
  if (!refs || !refs.current) return null;

  return (
    <>
      {(!position ||
        position === Position.top ||
        position === Position.bottom) && (
        <>
          <RoadmapConnectorHintItem
            x="50%"
            y="-2px"
            onClick={(x, y) => onSelect(id, x, y, Position.top)}
            onEnter={(x, y) => onHintEnter(id, x, y, Position.top)}
            onLeave={() => onHintLeave(id)}
          />
          <RoadmapConnectorHintItem
            x="50%"
            y="calc(100% + 2px)"
            onClick={(x, y) => onSelect(id, x, y, Position.bottom)}
            onEnter={(x, y) => onHintEnter(id, x, y, Position.bottom)}
            onLeave={() => onHintLeave(id)}
          />
        </>
      )}
      {(!position ||
        position === Position.left ||
        position === Position.right) && (
        <>
          <RoadmapConnectorHintItem
            x="-2px"
            y="50%"
            onClick={(x, y) => onSelect(id, x, y, Position.left)}
            onEnter={(x, y) => onHintEnter(id, x, y, Position.left)}
            onLeave={() => onHintLeave(id)}
          />
          <RoadmapConnectorHintItem
            x="calc(100% + 2px)"
            y="50%"
            onClick={(x, y) => onSelect(id, x, y, Position.right)}
            onEnter={(x, y) => onHintEnter(id, x, y, Position.right)}
            onLeave={() => onHintLeave(id)}
          />
        </>
      )}
    </>
  );
};

 

이후 connect 모드에서 로드맵 항목에 마우스를 올릴 시 힌트가 표시되도록 했다.

{editMode === EditMode.Connect &&
  roadmapItem.id === connectorHintId &&
  !(
    connectorStatus !== undefined &&
    roadmapItem.parentId !== null
  ) && (
    <RoadmapConnectorHint
      id={connectorHintId}
      refs={roadmapItemRefs.current[connectorHintId]}
      onSelect={onConnectorHintSelect}
      onHintEnter={onConnectorHintEnter}
      onHintLeave={onConnectorHintLeave}
      position={connectorStatus?.position}
    />
  )}

연결 기능 동작 확인


로드맵 항목 드래그 (위치 변경)

커서 버튼

react-draggable 라이브러리를 사용해 드래그를 구현했다.

https://www.npmjs.com/package/react-draggable

 

react-draggable

React draggable component. Latest version: 4.4.5, last published: 9 months ago. Start using react-draggable in your project by running `npm i react-draggable`. There are 1525 other projects in the npm registry using react-draggable.

www.npmjs.com

 

모드가 커서가 아니거나 이름 편집중일 경우 드래그를 비활성화 했다.

<Draggable
  onDrag={handleDrag}
  disabled={editMode !== EditMode.Cursor || editing}
>
...
</Draggable>

 

드래그 시 현재 위치를 계산해 콜백 함수로 넘겨주도록 구현했다.

const handleDrag: DraggableEventHandler = () => {
  const { x, y } = getCurrentPositionRem(refs.current);

  roadmapItem.x = x;
  roadmapItem.y = y;

  setPosition(getCurrentPositionRem(refs.current));
  onDrag(roadmapItem.id, x, y);
};

로드맵 항목 이름 편집

로드맵 항목의 기본 텍스트가 "Example"이기 때문에, 더블클릭을 해 이를 변경할 수 있도록 했다. 싱글 클릭과 더블 클릭 시 다른 동작을 해야 하는데, 기본적으로는 더블 클릭 시 싱글 클릭 이벤트까지 같이 발생된다. 때문에 일정 시간 안에 두번 클릭되면 더블클릭, 클릭 이후 일정 시간이 지나면 싱글 클릭으로 판별하게 했다.

 

더블클릭은 커서 모드일때만 작동하면 되므로 다른 모드일 땐 바로 클릭되도록 했다.

const handleClick: MouseEventHandler<HTMLDivElement> = () => {
  if (editing) return;

  const { x, y } = getCurrentPositionRem(refs.current);
  const click = () => {
    if (position === undefined || (position.x === x && position.y === y)) {
      onClick(roadmapItem.id);
    }
    setPosition(undefined);
  };

  if (editMode === EditMode.Cursor) {
    if (singleClicked) return;
    singleClicked = true;

    setTimeout(() => {
      if (!singleClicked) return;
      singleClicked = false;

      click();
    }, 300);
  } else {
    click();
  }
};

const handleDoubleClick = () => {
  if (!singleClicked) return;
  singleClicked = false;

  if (editMode === EditMode.Cursor) {
    setEditing(true);
  }
};

 

editing 상태일때 로드맵 항목 내부 element가 p에서 input으로 바뀌며, 이름을 수정할 수 있다. Enter 키를 누르거나 focus에서 벗어나면 자동으로 저장된다.

const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  roadmapItem.name = event.target.value;
  setInputText(event.target.value);
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
  if (event.key === "Enter") {
    inputRef.current?.blur();
  }
};

const handleBlur = () => {
  if (inputText === "") {
    setInputText("Example");
  }

  setEditing(false);
};

{editing ? (
  <input
    className="max-w-sm"
    ref={inputRef}
    type="text"
    value={inputText}
    onChange={handleTextChange}
    onKeyDown={handleKeyDown}
    onBlur={handleBlur}
    style={{ width: `${inputWidth}px` }}
  />
) : (
  inputText
)}

 

드래그 및 이름 변경 기능 동작 확인


로드맵 항목 삭제

삭제 버튼

추가했던 로드맵 항목을 삭제하는 기능도 만들어야 한다.

로드맵 항목을 클릭하여 동작하며, 더블클릭은 필요없기 때문에 비활성화 한다.

 

항목 삭제와 동시에 연결된 연결선들도 삭제되도록 했다.

const onRoadmapItemClick = (id: number) => {
  switch (editMode) {
  
    ...

    case EditMode.Delete:
      setRoadmapItemList(roadmapItemList.filter((r) => r.id !== id));
      roadmapItemList
        .filter((r) => r.parentId === id)
        .forEach((r) => {
          r.connectionType = null;
          r.parentId = null;
        });
      removeRef(id);
      updateScrollHeight();
      break;
  }
};

삭제 기능 동작 확인

 


로드맵 항목 정보 편집

추가된 로드맵 항목의 정보를 편집할 수 있다.

편집 가능한 정보는 다음과 같다.

 

  • 추천 정도
  • 로드맵 항목과 관련된 내용
  • 레퍼런스 사이트

 

로드맵 항목 시 우측에서 Drawer가 열리며, Mantine의 Drawer를 사용했다.

 

Mantine

You've submitted a pull request Fix incorrect notification message (#187) 34 minutes ago

mantine.dev

 

추천 정도는 Mantine의 Select를 사용했으며, 기본 Select는 메뉴에 Text밖에 나오지 않기 때문에 커스텀 컴포넌트를 작성해 넣어줬다.

const SelectItem = forwardRef<HTMLDivElement, ItemProps>(
  ({ value, label, ...others }, ref) => (
    <div ref={ref} {...others}>
      <div className="flex flex-row items-center">
        <RoadmapRecommendIcon recommend={value} className="mr-2" />
        <p>{label}</p>
      </div>
    </div>
  )
);


로드맵 업로드

로드맵 작성 완료 후 완료 버튼을 누르며 화면 하단에서 Drawer가 나오고, 이를 통해 로드맵을 발행할 수 있다.

공개 여부, 썸네일 업로드가 가능하다.

 

사진 업로드 버튼은 Mantine의 DropZone을 사용했으며, png, jpeg, gif 파일만 받아들이도록 설정했다.

<Dropzone
  className="w-full h-full"
  loading={imageUpload.isLoading}
  accept={[MIME_TYPES.png, MIME_TYPES.jpeg, MIME_TYPES.gif]}
  onDrop={handleImageDrop}
>
  <div>{}</div>
</Dropzone>

 

반응형