-
Notifications
You must be signed in to change notification settings - Fork 3
[FE] 화이트보드 캔버스 구현 #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FE] 화이트보드 캔버스 구현 #172
Changes from 14 commits
771f10c
50ddf05
bf45112
bd29969
83778e8
918905d
85ca01a
ab4ed5a
0344b6b
c65a71d
edb6eab
5b502f6
0078be2
284548f
366ea85
a1cd7ea
bf414e4
588123b
a0a1d23
844c3ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| 'use client'; | ||
|
|
||
| import { useRef, useEffect, useState, useMemo } from 'react'; | ||
| import Konva from 'konva'; | ||
| import { Stage, Layer, Rect } from 'react-konva'; | ||
| import { useCanvasStore } from '@/store/useCanvasStore'; | ||
| import type { WhiteboardItem, TextItem, ArrowItem } from '@/types/whiteboard'; | ||
| import { useWindowSize } from '@/hooks/useWindowSize'; | ||
| import { useCanvasInteraction } from '@/hooks/useCanvasInteraction'; | ||
| import { useArrowHandles } from '@/hooks/useArrowHandles'; | ||
| import RenderItem from '@/components/whiteboard/items/RenderItem'; | ||
| import TextArea from '@/components/whiteboard/items/text/TextArea'; | ||
| import ItemTransformer from '@/components/whiteboard/controls/ItemTransformer'; | ||
| import ArrowHandles from '@/components/whiteboard/items/arrow/ArrowHandles'; | ||
|
|
||
| export default function Canvas() { | ||
| const stageScale = useCanvasStore((state) => state.stageScale); | ||
| const stagePos = useCanvasStore((state) => state.stagePos); | ||
| const canvasWidth = useCanvasStore((state) => state.canvasWidth); | ||
| const canvasHeight = useCanvasStore((state) => state.canvasHeight); | ||
| const items = useCanvasStore((state) => state.items); | ||
| const selectedId = useCanvasStore((state) => state.selectedId); | ||
| const editingTextId = useCanvasStore((state) => state.editingTextId); | ||
| const selectItem = useCanvasStore((state) => state.selectItem); | ||
| const updateItem = useCanvasStore((state) => state.updateItem); | ||
| const deleteItem = useCanvasStore((state) => state.deleteItem); | ||
| const setEditingTextId = useCanvasStore((state) => state.setEditingTextId); | ||
|
|
||
| const stageRef = useRef<Konva.Stage | null>(null); | ||
| const [isDraggingArrow, setIsDraggingArrow] = useState(false); | ||
|
|
||
| const size = useWindowSize(); | ||
| const { handleWheel, handleDragMove, handleDragEnd } = useCanvasInteraction( | ||
| size.width, | ||
| size.height, | ||
| ); | ||
|
|
||
| const editingItem = useMemo( | ||
| () => items.find((item) => item.id === editingTextId) as TextItem | undefined, | ||
| [items, editingTextId] | ||
| ); | ||
|
|
||
| const selectedItem = useMemo( | ||
| () => items.find((item) => item.id === selectedId), | ||
| [items, selectedId] | ||
| ); | ||
|
|
||
| const isArrowSelected = selectedItem?.type === 'arrow'; | ||
|
|
||
| const { | ||
| selectedHandleIndex, | ||
| setSelectedHandleIndex, | ||
| handleHandleClick, | ||
| handleArrowStartDrag, | ||
| handleArrowControlPointDrag, | ||
| handleArrowEndDrag, | ||
| handleArrowDblClick, | ||
| deleteControlPoint, | ||
| } = useArrowHandles({ | ||
| arrow: isArrowSelected ? (selectedItem as ArrowItem) : null, | ||
| stageRef, | ||
| updateItem, | ||
| }); | ||
|
|
||
| // 키보드 삭제 | ||
| useEffect(() => { | ||
| const handleKeyDown = (e: KeyboardEvent) => { | ||
| if (!selectedId || editingTextId) return; | ||
|
|
||
| if (e.key === 'Delete' || e.key === 'Backspace') { | ||
| e.preventDefault(); | ||
|
|
||
| // 화살표 중간점 삭제 시도 | ||
| if (isArrowSelected && selectedHandleIndex !== null) { | ||
| const deleted = deleteControlPoint(); | ||
| if (deleted) return; | ||
| } | ||
|
|
||
| // 아이템 삭제 | ||
| deleteItem(selectedId); | ||
| } | ||
| }; | ||
|
|
||
| window.addEventListener('keydown', handleKeyDown); | ||
| return () => window.removeEventListener('keydown', handleKeyDown); | ||
| }, [selectedId, editingTextId, deleteItem, isArrowSelected, selectedHandleIndex, deleteControlPoint]); | ||
|
|
||
| // 선택 해제 | ||
| const handleCheckDeselect = ( | ||
| e: Konva.KonvaEventObject<MouseEvent | TouchEvent>, | ||
| ) => { | ||
| if (editingTextId) return; | ||
|
|
||
| const clickedOnEmpty = | ||
| e.target === e.target.getStage() || e.target.hasName('bg-rect'); | ||
|
|
||
| if (clickedOnEmpty) { | ||
| selectItem(null); | ||
| setSelectedHandleIndex(null); | ||
| } | ||
| }; | ||
|
|
||
| // 아이템 업데이트 | ||
| const handleItemChange = ( | ||
| id: string, | ||
| newAttributes: Partial<WhiteboardItem>, | ||
| ) => { | ||
| updateItem(id, newAttributes); | ||
| }; | ||
|
|
||
| if (size.width === 0 || size.height === 0) return null; | ||
|
|
||
| return ( | ||
| <div className="relative h-screen w-screen overflow-hidden bg-neutral-100"> | ||
| <Stage | ||
| ref={stageRef} | ||
| width={size.width} | ||
| height={size.height} | ||
| draggable | ||
| x={stagePos.x} | ||
| y={stagePos.y} | ||
| scaleX={stageScale} | ||
| scaleY={stageScale} | ||
| onWheel={handleWheel} | ||
| onDragMove={handleDragMove} | ||
| onDragEnd={handleDragEnd} | ||
| onMouseDown={handleCheckDeselect} | ||
| onTouchStart={handleCheckDeselect} | ||
| > | ||
| <Layer | ||
| clipX={0} | ||
| clipY={0} | ||
| clipWidth={canvasWidth} | ||
| clipHeight={canvasHeight} | ||
| > | ||
| {/* Canvas 경계 */} | ||
| <Rect | ||
| name="bg-rect" | ||
| x={0} | ||
| y={0} | ||
| width={canvasWidth} | ||
| height={canvasHeight} | ||
| fill="white" | ||
| stroke="gray" | ||
| strokeWidth={2} | ||
| listening={true} | ||
| /> | ||
|
|
||
| {/* 아이템 렌더링 */} | ||
| {items.map((item) => ( | ||
| <RenderItem | ||
| key={item.id} | ||
| item={item} | ||
| isSelected={item.id === selectedId} | ||
| onSelect={selectItem} | ||
| onChange={(newAttributes) => | ||
| handleItemChange(item.id, newAttributes) | ||
| } | ||
| onArrowDblClick={handleArrowDblClick} | ||
| onDragStart={() => { | ||
| if (item.type === 'arrow') { | ||
| setIsDraggingArrow(true); | ||
| } | ||
| }} | ||
| onDragEnd={() => { | ||
| if (item.type === 'arrow') { | ||
| setIsDraggingArrow(false); | ||
| } | ||
| }} | ||
| /> | ||
| ))} | ||
|
|
||
| {/* 화살표 핸들 (드래그 중이 아닐 때만) */} | ||
| {isArrowSelected && selectedItem && !isDraggingArrow && ( | ||
| <ArrowHandles | ||
| arrow={selectedItem as ArrowItem} | ||
| selectedHandleIndex={selectedHandleIndex} | ||
| onHandleClick={handleHandleClick} | ||
| onStartDrag={handleArrowStartDrag} | ||
| onControlPointDrag={handleArrowControlPointDrag} | ||
| onEndDrag={handleArrowEndDrag} | ||
| /> | ||
| )} | ||
|
|
||
| {/* Transformer */} | ||
| <ItemTransformer | ||
| selectedId={selectedId} | ||
| items={items} | ||
| stageRef={stageRef} | ||
| /> | ||
| </Layer> | ||
| </Stage> | ||
|
|
||
| {/* 텍스트 편집 모드 */} | ||
| {editingTextId && editingItem && ( | ||
| <TextArea | ||
| textId={editingTextId} | ||
| textItem={editingItem} | ||
| stageRef={stageRef} | ||
| onChange={(newText) => { | ||
| updateItem(editingTextId, { text: newText }); | ||
| }} | ||
| onClose={() => { | ||
| setEditingTextId(null); | ||
| selectItem(null); | ||
| }} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // 캔버스 크기 | ||
| export const CANVAS_WIDTH = 20000; | ||
| export const CANVAS_HEIGHT = 20000; | ||
|
|
||
| // 줌 제한 | ||
| export const MIN_SCALE = 0.1; | ||
| export const MAX_SCALE = 10; | ||
| export const SCALE_BY = 1.1; // 줌 배율 (10%씩) | ||
|
|
||
| export const ZOOM_STEP = 0.1; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useRef } from 'react'; | ||
| import Konva from 'konva'; | ||
| import { Transformer } from 'react-konva'; | ||
| import type { WhiteboardItem } from '@/types/whiteboard'; | ||
|
|
||
| interface ItemTransformerProps { | ||
| selectedId: string | null; | ||
| items: WhiteboardItem[]; | ||
| stageRef: React.RefObject<Konva.Stage | null>; | ||
| } | ||
|
|
||
| export default function ItemTransformer({ | ||
| selectedId, | ||
| items, | ||
| stageRef, | ||
| }: ItemTransformerProps) { | ||
| const transformerRef = useRef<Konva.Transformer | null>(null); | ||
|
|
||
| const selectedItem = items.find((item) => item.id === selectedId); | ||
| const isTextSelected = selectedItem?.type === 'text'; | ||
| const isArrowSelected = selectedItem?.type === 'arrow'; | ||
|
|
||
| // Transformer 연결 (화살표는 제외) | ||
| useEffect(() => { | ||
| if (transformerRef.current && stageRef.current) { | ||
| const stage = stageRef.current; | ||
|
|
||
| if (selectedId && !isArrowSelected) { | ||
| const selectedNode = stage.findOne('#' + selectedId); | ||
| if (selectedNode) { | ||
| transformerRef.current.nodes([selectedNode]); | ||
| transformerRef.current.getLayer()?.batchDraw(); | ||
| } else { | ||
| transformerRef.current.nodes([]); | ||
| } | ||
| } else { | ||
| transformerRef.current.nodes([]); | ||
| } | ||
|
Comment on lines
+30
to
+40
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p4: early-return 패턴을 시도해봐도 좋을 것 같아요! |
||
| } | ||
| }, [selectedId, items, stageRef, isArrowSelected]); | ||
|
|
||
| return ( | ||
| <Transformer | ||
| ref={transformerRef} | ||
| enabledAnchors={ | ||
| isTextSelected | ||
| ? ['middle-left', 'middle-right'] | ||
| : [ | ||
| 'top-left', | ||
| 'top-right', | ||
| 'bottom-left', | ||
| 'bottom-right', | ||
| 'top-center', | ||
| 'bottom-center', | ||
| 'middle-left', | ||
| 'middle-right', | ||
| ] | ||
| } | ||
| anchorSize={10} | ||
| anchorCornerRadius={5} | ||
| anchorStrokeWidth={1.5} | ||
| anchorStroke="#0369A1" | ||
| borderStroke="#0369A1" | ||
| borderStrokeWidth={1.5} | ||
| rotationSnaps={[0, 90, 180, 270]} | ||
| rotationSnapTolerance={10} | ||
| keepRatio={false} | ||
| boundBoxFunc={(_oldBox, newBox) => { | ||
| newBox.width = Math.max(30, newBox.width); | ||
| return newBox; | ||
| }} | ||
| onTransform={(e) => { | ||
| // Transform 중에도 스케일 보정 | ||
| const node = e.target; | ||
| const scaleX = node.scaleX(); | ||
| const scaleY = node.scaleY(); | ||
|
|
||
| if (scaleX !== 1 || scaleY !== 1) { | ||
| node.scaleX(1); | ||
| node.scaleY(1); | ||
|
|
||
| if (node.getClassName() === 'Text') { | ||
| node.width(node.width() * scaleX); | ||
| } | ||
|
Comment on lines
+84
to
+86
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Text만 보정한 이유가 텍스트 배율에 따라 찌그러져서 그런 걸까요!? 일반 도형아이템은 해당 로직 없이도 잘 보정되나요?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 맞아요 리시가 언급한 대로 요소 박스(Transformer)를 늘려 크기 변화시 각 요쇼의 width, height가 변경되는게 아니라 그만큼의 scaleX / scaleY 확대 수준이 변화하게 되는데 텍스트의 경우는 박스를 늘려도 텍스트 자체 크기를 유지하도록 보정 로직을 추가하였습니다. 일반 도형 아이템은 해도 안해도 보이는 결과는 같긴하지만 높이 너비만 변화시키는게 바람직해보여 추후에 요소 추가시 보정 로직도 추가하면 좋지 않을까 생각하고 있어요. |
||
| } | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
p5: 추후에 /items나 /controls에서 가져올 컴포넌트가 더 많아진다면 import line을 줄이기 위해 각 컴포넌트별로 export 모아두는 것도 좋을 것 같아요!