Intro
백엔드 API 구현이 완료돼서 사용자가 로드맵을 만들고 업로드할 수 있는 에디터를 만들었다.
이 프로젝트를 하며 가장 구현하기 힘들었다.
소스코드는 Github에 업로드 되어있습니다.
GitHub - DevJaewoo/OpenRoadmaps
Contribute to DevJaewoo/OpenRoadmaps development by creating an account on GitHub.
github.com
UI 구성
편집기 UI는 아래와 같다.
- 로드맵 항목 리스트: 편집 영역의 로드맵 항목들을 확인하고 선택할 수 있다.
- 로드맵 편집 영역: 이 영역에서 로드맵을 만들 수 있다.
- 로드맵 편집 모드 선택: 아이콘을 선택해 편집 모드를 변경한다.
로드맵 편집 아이콘 클릭 이벤트를 수신하게 했고,
<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>
'Projects > OpenRoadmaps' 카테고리의 다른 글
[OpenRoadmaps] 8. 블로그 글 작성, 글 뷰어 기능 개발 (2) | 2023.02.05 |
---|---|
[OpenRoadmaps] 7. 로드맵 뷰어 기능 개발 (0) | 2023.02.05 |
[OpenRoadmaps] 5. 로드맵 API 구현 및 테스트 (0) | 2023.01.05 |
[OpenRoadmaps] 4. 테스트 환경 구성 및 테스트 항목 (0) | 2023.01.05 |
[OpenRoadmaps] 3. 이메일 및 OAuth2 로그인 구현 (0) | 2022.11.28 |