Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
771f10c
📍 Feat: 캔버스 화이트보드 기능 구현
tjsdn052 Jan 6, 2026
50ddf05
📍 Feat: zoomControl 기능 추가
tjsdn052 Jan 6, 2026
bf45112
🤖 Refactor: 캔버스 조작 훅으로 분리
tjsdn052 Jan 6, 2026
bd29969
🚚 Chore: 좌표 변환 유틸 디렉토리 변경
tjsdn052 Jan 7, 2026
83778e8
🚚 Chore: 캔버스 관련 상수 위치 이동
tjsdn052 Jan 7, 2026
918905d
📍 Feat: whiteboard 타입 정의
tjsdn052 Jan 7, 2026
85ca01a
📍 Feat: 화이트보드 캔버스 스토어 구현
tjsdn052 Jan 7, 2026
ab4ed5a
📍 Feat: - 아이템 변형시 나타나는 Transformer 구현
tjsdn052 Jan 7, 2026
0344b6b
📍 Feat: 텍스트 추가 및 편집 구현
tjsdn052 Jan 7, 2026
c65a71d
📍 Feat: 화이트보트 메인 캔버스
tjsdn052 Jan 7, 2026
edb6eab
📍 Feat: 화살표 관련 유틸 함수 추가
tjsdn052 Jan 7, 2026
5b502f6
📍 Feat: 화살표 추가 구현
tjsdn052 Jan 7, 2026
0078be2
🚚 Chore: 유틸 함수 위치 변경
tjsdn052 Jan 7, 2026
284548f
📍 Feat: textArea 생성 위치 수정
tjsdn052 Jan 7, 2026
366ea85
📍 Feat: 화살표 클릭 범위 증가
tjsdn052 Jan 8, 2026
a1cd7ea
🚚 Chore: arrow.ts 매직넘버 상수로 분리
tjsdn052 Jan 8, 2026
bf414e4
📍 Feat: 화살표 중간 핸들 조작시 더 부드럽게 휘어지도록 변경
tjsdn052 Jan 8, 2026
588123b
✂️ Remove: 충돌 해결
tjsdn052 Jan 8, 2026
a0a1d23
🔨 Fix: 빌드 오류 수정
tjsdn052 Jan 8, 2026
844c3ba
Merge branch 'dev' into feature/#166/workspace-canvas
tjsdn052 Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion frontend/src/app/whiteboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
'use client';

import dynamic from 'next/dynamic';

import HistoryControl from '@/components/whiteboard/controls/HistoryControl';
import OverlayControl from '@/components/whiteboard/controls/OverlayControl';
import ZoomControls from '@/components/whiteboard/controls/ZoomControl';

import Sidebar from '@/components/whiteboard/sidebar/Sidebar';
import ToolbarContainer from '@/components/whiteboard/toolbar/ToolbarContainer';

const Canvas = dynamic(() => import('@/components/whiteboard/Canvas'), {
ssr: false,
loading: () => <p>Loading...</p>,
});

export default function Home() {
return (
<div className="relative flex min-h-screen items-center justify-center">
Expand All @@ -13,6 +21,7 @@ export default function Home() {
<HistoryControl />
<ZoomControls />
<OverlayControl />
<Canvas />
</div>
);
}
211 changes: 211 additions & 0 deletions frontend/src/components/whiteboard/Canvas.tsx
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';
Comment on lines +11 to +14
Copy link
Collaborator

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 모아두는 것도 좋을 것 같아요!


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>
);
}
10 changes: 10 additions & 0 deletions frontend/src/components/whiteboard/constants/canvas.ts
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;
91 changes: 91 additions & 0 deletions frontend/src/components/whiteboard/controls/ItemTransformer.tsx
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Text만 보정한 이유가 텍스트 배율에 따라 찌그러져서 그런 걸까요!? 일반 도형아이템은 해당 로직 없이도 잘 보정되나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞아요 리시가 언급한 대로 요소 박스(Transformer)를 늘려 크기 변화시 각 요쇼의 width, height가 변경되는게 아니라 그만큼의 scaleX / scaleY 확대 수준이 변화하게 되는데 텍스트의 경우는 박스를 늘려도 텍스트 자체 크기를 유지하도록 보정 로직을 추가하였습니다.

일반 도형 아이템은 해도 안해도 보이는 결과는 같긴하지만 높이 너비만 변화시키는게 바람직해보여 추후에 요소 추가시 보정 로직도 추가하면 좋지 않을까 생각하고 있어요.

}
}}
/>
);
}
Loading