- Hello Clobby !
+
+
);
}
diff --git a/frontend/src/app/whiteboard/page.tsx b/frontend/src/app/whiteboard/page.tsx
new file mode 100644
index 000000000..bd282c9e0
--- /dev/null
+++ b/frontend/src/app/whiteboard/page.tsx
@@ -0,0 +1,27 @@
+'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: () =>
Loading...
,
+});
+
+export default function Home() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/workspace/page.tsx b/frontend/src/app/workspace/page.tsx
deleted file mode 100644
index 9cbf75bd2..000000000
--- a/frontend/src/app/workspace/page.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-'use client';
-
-import React from 'react';
-import dynamic from 'next/dynamic';
-import { useWorkspaceStore } from '@/store/useWorkspaceStore';
-import Sidebar from '@/components/workspace/Sidebar';
-import ToolbarManager from '@/components/workspace/toolbar/ToolbarManager';
-
-// Konva Stage : 브라우저 API(window)를 사용 -> ssr: false 설정 필수
-const WorkspaceStage = dynamic(
- () => import('@/components/workspace/WorkspaceStage'),
- {
- ssr: false,
- loading: () => (
-
- {/* spinner 구현 */}
- 카드제작소 로딩 중...
-
- ),
- },
-);
-
-export default function WorkspacePage() {
- const { cardData, zoom } = useWorkspaceStore();
-
- return (
-
-
-
-
-
-
-
- {/* 워크스페이스 영역*/}
-
-
-
-
- {/* 하단 카드 정보 바 */}
- {/* TODO : 추후 슬라이드로 변경 예정 */}
-
-
-
- );
-}
diff --git a/frontend/src/assets/TarotBack.png b/frontend/src/assets/TarotBack.png
deleted file mode 100644
index c1baa2474..000000000
Binary files a/frontend/src/assets/TarotBack.png and /dev/null differ
diff --git a/frontend/src/assets/icons/common/arrowDownIcon.svg b/frontend/src/assets/icons/common/arrowDownIcon.svg
new file mode 100644
index 000000000..7b4743a93
--- /dev/null
+++ b/frontend/src/assets/icons/common/arrowDownIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/chevronLeftIcon.svg b/frontend/src/assets/icons/common/chevronLeftIcon.svg
new file mode 100644
index 000000000..2a56a4958
--- /dev/null
+++ b/frontend/src/assets/icons/common/chevronLeftIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/chevronRightIcon.svg b/frontend/src/assets/icons/common/chevronRightIcon.svg
new file mode 100644
index 000000000..93e419c28
--- /dev/null
+++ b/frontend/src/assets/icons/common/chevronRightIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/closeIcon.svg b/frontend/src/assets/icons/common/closeIcon.svg
new file mode 100644
index 000000000..b5364148c
--- /dev/null
+++ b/frontend/src/assets/icons/common/closeIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/copyIcon.svg b/frontend/src/assets/icons/common/copyIcon.svg
new file mode 100644
index 000000000..a07b13e65
--- /dev/null
+++ b/frontend/src/assets/icons/common/copyIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/editIcon.svg b/frontend/src/assets/icons/common/editIcon.svg
new file mode 100644
index 000000000..dc421584e
--- /dev/null
+++ b/frontend/src/assets/icons/common/editIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/eyeOffIcon.svg b/frontend/src/assets/icons/common/eyeOffIcon.svg
new file mode 100644
index 000000000..6ffbea6fd
--- /dev/null
+++ b/frontend/src/assets/icons/common/eyeOffIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/eyeOnIcon.svg b/frontend/src/assets/icons/common/eyeOnIcon.svg
new file mode 100644
index 000000000..cbdf15135
--- /dev/null
+++ b/frontend/src/assets/icons/common/eyeOnIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/imageIcon.svg b/frontend/src/assets/icons/common/imageIcon.svg
new file mode 100644
index 000000000..df3c3bb78
--- /dev/null
+++ b/frontend/src/assets/icons/common/imageIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/index.ts b/frontend/src/assets/icons/common/index.ts
new file mode 100644
index 000000000..ce40400de
--- /dev/null
+++ b/frontend/src/assets/icons/common/index.ts
@@ -0,0 +1,14 @@
+export { default as ArrowDownIcon } from './arrowDownIcon.svg';
+export { default as ChevronLeftIcon } from './chevronLeftIcon.svg';
+export { default as ChevronRightIcon } from './chevronRightIcon.svg';
+export { default as CloseIcon } from './closeIcon.svg';
+export { default as CopyIcon } from './copyIcon.svg';
+export { default as EditIcon } from './editIcon.svg';
+export { default as EyeOffIcon } from './eyeOffIcon.svg';
+export { default as EyeOnIcon } from './eyeOnIcon.svg';
+export { default as ImageIcon } from './imageIcon.svg';
+export { default as MinusIcon } from './minusIcon.svg';
+export { default as MoreHoriIcon } from './moreHoriIcon.svg';
+export { default as MoreVertIcon } from './moreVertIcon.svg';
+export { default as PlusIcon } from './plusIcon.svg';
+export { default as ShareIcon } from './shareIcon.svg';
diff --git a/frontend/src/assets/icons/common/minusIcon.svg b/frontend/src/assets/icons/common/minusIcon.svg
new file mode 100644
index 000000000..222fcbfb9
--- /dev/null
+++ b/frontend/src/assets/icons/common/minusIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/moreHoriIcon.svg b/frontend/src/assets/icons/common/moreHoriIcon.svg
new file mode 100644
index 000000000..f2e85005e
--- /dev/null
+++ b/frontend/src/assets/icons/common/moreHoriIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/moreVertIcon.svg b/frontend/src/assets/icons/common/moreVertIcon.svg
new file mode 100644
index 000000000..89cd3c419
--- /dev/null
+++ b/frontend/src/assets/icons/common/moreVertIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/plusIcon.svg b/frontend/src/assets/icons/common/plusIcon.svg
new file mode 100644
index 000000000..b5854ea49
--- /dev/null
+++ b/frontend/src/assets/icons/common/plusIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/common/shareIcon.svg b/frontend/src/assets/icons/common/shareIcon.svg
new file mode 100644
index 000000000..4b799b474
--- /dev/null
+++ b/frontend/src/assets/icons/common/shareIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/camOffIcon.svg b/frontend/src/assets/icons/meeting/camOffIcon.svg
new file mode 100644
index 000000000..f8db39f95
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/camOffIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/camOnIcon.svg b/frontend/src/assets/icons/meeting/camOnIcon.svg
new file mode 100644
index 000000000..3db8a6e90
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/camOnIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/chatIcon.svg b/frontend/src/assets/icons/meeting/chatIcon.svg
new file mode 100644
index 000000000..3073cc201
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/chatIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/codeIcon.svg b/frontend/src/assets/icons/meeting/codeIcon.svg
new file mode 100644
index 000000000..c98577826
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/codeIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/downloadIcon.svg b/frontend/src/assets/icons/meeting/downloadIcon.svg
new file mode 100644
index 000000000..1d05559b2
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/downloadIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/exitMeetingIcon.svg b/frontend/src/assets/icons/meeting/exitMeetingIcon.svg
new file mode 100644
index 000000000..36d2b4eea
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/exitMeetingIcon.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/meeting/fileIcon.svg b/frontend/src/assets/icons/meeting/fileIcon.svg
new file mode 100644
index 000000000..7419e9421
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/fileIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/index.ts b/frontend/src/assets/icons/meeting/index.ts
new file mode 100644
index 000000000..30ebb8b3e
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/index.ts
@@ -0,0 +1,16 @@
+export { default as CamOffIcon } from './camOffIcon.svg';
+export { default as CamOnIcon } from './camOnIcon.svg';
+export { default as ChatIcon } from './chatIcon.svg';
+export { default as CodeIcon } from './codeIcon.svg';
+export { default as DownloadIcon } from './downloadIcon.svg';
+export { default as ExitMeetingIcon } from './exitMeetingIcon.svg';
+export { default as FileIcon } from './fileIcon.svg';
+export { default as InfoIcon } from './infoIcon.svg';
+export { default as MarkedChatIcon } from './markedChatIcon.svg';
+export { default as MemberIcon } from './memberIcon.svg';
+export { default as MicOffIcon } from './micOffIcon.svg';
+export { default as MicOnIcon } from './micOnIcon.svg';
+export { default as SendIcon } from './sendIcon.svg';
+export { default as ShareIcon } from './shareIcon.svg';
+export { default as VolumnIcon } from './volumnIcon.svg';
+export { default as WorkspaceIcon } from './workspaceIcon.svg';
diff --git a/frontend/src/assets/icons/meeting/infoIcon.svg b/frontend/src/assets/icons/meeting/infoIcon.svg
new file mode 100644
index 000000000..e2ae61e17
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/infoIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/markedChatIcon.svg b/frontend/src/assets/icons/meeting/markedChatIcon.svg
new file mode 100644
index 000000000..f47520031
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/markedChatIcon.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/icons/meeting/memberIcon.svg b/frontend/src/assets/icons/meeting/memberIcon.svg
new file mode 100644
index 000000000..cdcd24334
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/memberIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/micOffIcon.svg b/frontend/src/assets/icons/meeting/micOffIcon.svg
new file mode 100644
index 000000000..d6b3d25e6
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/micOffIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/micOnIcon.svg b/frontend/src/assets/icons/meeting/micOnIcon.svg
new file mode 100644
index 000000000..6a20c628c
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/micOnIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/sendIcon.svg b/frontend/src/assets/icons/meeting/sendIcon.svg
new file mode 100644
index 000000000..bc3c3a0f4
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/sendIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/shareIcon.svg b/frontend/src/assets/icons/meeting/shareIcon.svg
new file mode 100644
index 000000000..76fdd9dbb
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/shareIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/volumnIcon.svg b/frontend/src/assets/icons/meeting/volumnIcon.svg
new file mode 100644
index 000000000..a0a430e17
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/volumnIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/meeting/workspaceIcon.svg b/frontend/src/assets/icons/meeting/workspaceIcon.svg
new file mode 100644
index 000000000..13ff55743
--- /dev/null
+++ b/frontend/src/assets/icons/meeting/workspaceIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/arrow/arrowIcon.svg b/frontend/src/assets/icons/whiteboard/arrow/arrowIcon.svg
new file mode 100644
index 000000000..9ac4bf719
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/arrow/arrowIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/arrow/chevronArrowIcon.svg b/frontend/src/assets/icons/whiteboard/arrow/chevronArrowIcon.svg
new file mode 100644
index 000000000..cb5102922
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/arrow/chevronArrowIcon.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/arrow/doubleArrowIcon.svg b/frontend/src/assets/icons/whiteboard/arrow/doubleArrowIcon.svg
new file mode 100644
index 000000000..ccfbb031b
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/arrow/doubleArrowIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/arrow/index.ts b/frontend/src/assets/icons/whiteboard/arrow/index.ts
new file mode 100644
index 000000000..1e220f742
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/arrow/index.ts
@@ -0,0 +1,4 @@
+// arrow icons
+export { default as ArrowIcon } from '@/assets/icons/whiteboard/arrow/arrowIcon.svg';
+export { default as DoubleArrowIcon } from '@/assets/icons/whiteboard/arrow/doubleArrowIcon.svg';
+export { default as ChevronArrowIcon } from '@/assets/icons/whiteboard/arrow/chevronArrowIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/common/bgColorIcon.svg b/frontend/src/assets/icons/whiteboard/common/bgColorIcon.svg
new file mode 100644
index 000000000..328bec876
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/common/bgColorIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/common/index.ts b/frontend/src/assets/icons/whiteboard/common/index.ts
new file mode 100644
index 000000000..b08efc479
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/common/index.ts
@@ -0,0 +1,2 @@
+// common icons
+export { default as BgColorIcon } from '@assets/icons/whiteboard/common/bgColorIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/control/closeIcon.svg b/frontend/src/assets/icons/whiteboard/control/closeIcon.svg
new file mode 100644
index 000000000..b5364148c
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/closeIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/control/index.ts b/frontend/src/assets/icons/whiteboard/control/index.ts
new file mode 100644
index 000000000..27439e81d
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/index.ts
@@ -0,0 +1,7 @@
+// control icons
+export { default as RedoIcon } from '@/assets/icons/whiteboard/control/redoIcon.svg';
+export { default as UndoIcon } from '@/assets/icons/whiteboard/control/undoIcon.svg';
+export { default as ZoomInIcon } from '@/assets/icons/whiteboard/control/zoomInIcon.svg';
+export { default as ZoomOutIcon } from '@/assets/icons/whiteboard/control/zoomOutIcon.svg';
+export { default as CloseIcon } from '@/assets/icons/whiteboard/control/closeIcon.svg';
+export { default as ShareIcon } from '@/assets/icons/whiteboard/control/shareIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/control/redoIcon.svg b/frontend/src/assets/icons/whiteboard/control/redoIcon.svg
new file mode 100644
index 000000000..bffa4c14f
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/redoIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/control/shareIcon.svg b/frontend/src/assets/icons/whiteboard/control/shareIcon.svg
new file mode 100644
index 000000000..4b799b474
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/shareIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/control/undoIcon.svg b/frontend/src/assets/icons/whiteboard/control/undoIcon.svg
new file mode 100644
index 000000000..c0c6074fd
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/undoIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/control/zoomInIcon.svg b/frontend/src/assets/icons/whiteboard/control/zoomInIcon.svg
new file mode 100644
index 000000000..0b1910fa1
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/zoomInIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/control/zoomOutIcon.svg b/frontend/src/assets/icons/whiteboard/control/zoomOutIcon.svg
new file mode 100644
index 000000000..dbf030314
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/control/zoomOutIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/cursor/cursorIcon.svg b/frontend/src/assets/icons/whiteboard/cursor/cursorIcon.svg
new file mode 100644
index 000000000..497b34c33
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/cursor/cursorIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/cursor/handIcon.svg b/frontend/src/assets/icons/whiteboard/cursor/handIcon.svg
new file mode 100644
index 000000000..f8d381c3e
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/cursor/handIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/cursor/index.ts b/frontend/src/assets/icons/whiteboard/cursor/index.ts
new file mode 100644
index 000000000..bd3d72726
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/cursor/index.ts
@@ -0,0 +1,3 @@
+// cursor icons
+export { default as CursorIcon } from '@/assets/icons/whiteboard/cursor/cursorIcon.svg';
+export { default as HandIcon } from '@/assets/icons/whiteboard/cursor/handIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/eraser/eraserIcon.svg b/frontend/src/assets/icons/whiteboard/eraser/eraserIcon.svg
new file mode 100644
index 000000000..fa09e94ec
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/eraser/eraserIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/eraser/index.ts b/frontend/src/assets/icons/whiteboard/eraser/index.ts
new file mode 100644
index 000000000..0145e3e32
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/eraser/index.ts
@@ -0,0 +1,2 @@
+// eraser icons
+export { default as EraserIcon } from '@/assets/icons/whiteboard/eraser/eraserIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/index.ts b/frontend/src/assets/icons/whiteboard/index.ts
new file mode 100644
index 000000000..6cda8711d
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/index.ts
@@ -0,0 +1,10 @@
+export * from '@/assets/icons/whiteboard/common';
+export * from '@/assets/icons/whiteboard/arrow';
+export * from '@/assets/icons/whiteboard/cursor';
+export * from '@/assets/icons/whiteboard/shape';
+export * from '@/assets/icons/whiteboard/line';
+export * from '@/assets/icons/whiteboard/pen';
+export * from '@/assets/icons/whiteboard/text';
+export * from '@/assets/icons/whiteboard/media';
+export * from '@/assets/icons/whiteboard/eraser';
+export * from '@/assets/icons/whiteboard/control';
diff --git a/frontend/src/assets/icons/whiteboard/line/index.ts b/frontend/src/assets/icons/whiteboard/line/index.ts
new file mode 100644
index 000000000..42b9090cc
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/line/index.ts
@@ -0,0 +1,2 @@
+// line icons
+export { default as LineIcon } from '@/assets/icons/whiteboard/line/lineIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/line/lineIcon.svg b/frontend/src/assets/icons/whiteboard/line/lineIcon.svg
new file mode 100644
index 000000000..8e7575d2d
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/line/lineIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/media/imageIcon.svg b/frontend/src/assets/icons/whiteboard/media/imageIcon.svg
new file mode 100644
index 000000000..df3c3bb78
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/media/imageIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/media/index.ts b/frontend/src/assets/icons/whiteboard/media/index.ts
new file mode 100644
index 000000000..75a7a26bb
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/media/index.ts
@@ -0,0 +1,4 @@
+// media icons
+export { default as ImageIcon } from '@/assets/icons/whiteboard/media/imageIcon.svg';
+export { default as VideoIcon } from '@/assets/icons/whiteboard/media/videoIcon.svg';
+export { default as YoutubeIcon } from '@/assets/icons/whiteboard/media/youtubeIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/media/videoIcon.svg b/frontend/src/assets/icons/whiteboard/media/videoIcon.svg
new file mode 100644
index 000000000..e940778db
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/media/videoIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/media/youtubeIcon.svg b/frontend/src/assets/icons/whiteboard/media/youtubeIcon.svg
new file mode 100644
index 000000000..a869c573f
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/media/youtubeIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/pen/index.ts b/frontend/src/assets/icons/whiteboard/pen/index.ts
new file mode 100644
index 000000000..44dba7bad
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/pen/index.ts
@@ -0,0 +1,2 @@
+// pen icons
+export { default as PenIcon } from '@/assets/icons/whiteboard/pen/penIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/pen/penIcon.svg b/frontend/src/assets/icons/whiteboard/pen/penIcon.svg
new file mode 100644
index 000000000..dc421584e
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/pen/penIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/shape/circleIcon.svg b/frontend/src/assets/icons/whiteboard/shape/circleIcon.svg
new file mode 100644
index 000000000..481ca9f65
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/circleIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/shape/diamondIcon.svg b/frontend/src/assets/icons/whiteboard/shape/diamondIcon.svg
new file mode 100644
index 000000000..1e2602067
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/diamondIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/shape/index.ts b/frontend/src/assets/icons/whiteboard/shape/index.ts
new file mode 100644
index 000000000..7dbf4c7e1
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/index.ts
@@ -0,0 +1,6 @@
+// shape icons
+export { default as CircleIcon } from '@/assets/icons/whiteboard/shape/circleIcon.svg';
+export { default as TriangleIcon } from '@/assets/icons/whiteboard/shape/triangleIcon.svg';
+export { default as SquareIcon } from '@/assets/icons/whiteboard/shape/squareIcon.svg';
+export { default as DiamondIcon } from '@/assets/icons/whiteboard/shape/diamondIcon.svg';
+export { default as PentagonIcon } from '@/assets/icons/whiteboard/shape/pentagonIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/shape/pentagonIcon.svg b/frontend/src/assets/icons/whiteboard/shape/pentagonIcon.svg
new file mode 100644
index 000000000..9de15cf63
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/pentagonIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/shape/squareIcon.svg b/frontend/src/assets/icons/whiteboard/shape/squareIcon.svg
new file mode 100644
index 000000000..a8c428989
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/squareIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/shape/triangleIcon.svg b/frontend/src/assets/icons/whiteboard/shape/triangleIcon.svg
new file mode 100644
index 000000000..80d65f6e8
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/shape/triangleIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/alignCenterIcon.svg b/frontend/src/assets/icons/whiteboard/text/alignCenterIcon.svg
new file mode 100644
index 000000000..81db7eefb
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/alignCenterIcon.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/alignLeftIcon.svg b/frontend/src/assets/icons/whiteboard/text/alignLeftIcon.svg
new file mode 100644
index 000000000..89e1bc738
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/alignLeftIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/alignRightIcon.svg b/frontend/src/assets/icons/whiteboard/text/alignRightIcon.svg
new file mode 100644
index 000000000..13415ed1b
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/alignRightIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/boldIcon.svg b/frontend/src/assets/icons/whiteboard/text/boldIcon.svg
new file mode 100644
index 000000000..8a9a72f0b
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/boldIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/index.ts b/frontend/src/assets/icons/whiteboard/text/index.ts
new file mode 100644
index 000000000..ec949f049
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/index.ts
@@ -0,0 +1,18 @@
+// text icons
+export { default as TextBoxIcon } from '@/assets/icons/whiteboard/text/textBoxIcon.svg';
+// text align icons
+export { default as AlignCenterIcon } from '@/assets/icons/whiteboard/text/alignCenterIcon.svg';
+export { default as AlignLeftIcon } from '@/assets/icons/whiteboard/text/alignLeftIcon.svg';
+export { default as AlignRightIcon } from '@/assets/icons/whiteboard/text/alignRightIcon.svg';
+// text style icons
+export { default as BoldIcon } from '@/assets/icons/whiteboard/text/boldIcon.svg';
+export { default as ItalicIcon } from '@/assets/icons/whiteboard/text/italicIcon.svg';
+// text orderlist icons
+export { default as OrderedListIcon } from '@/assets/icons/whiteboard/text/orderedListIcon.svg';
+export { default as UnorderListIcon } from '@/assets/icons/whiteboard/text/unorderedListIcon.svg';
+// text decorate line icons
+export { default as StrikeThroughIcon } from '@/assets/icons/whiteboard/text/strikeThroughIcon.svg';
+export { default as TextColorIcon } from '@/assets/icons/whiteboard/text/textColorIcon.svg';
+export { default as UnderlineIcon } from '@/assets/icons/whiteboard/text/underlineIcon.svg';
+// text link icon
+export { default as LinkIcon } from '@/assets/icons/whiteboard/text/linkIcon.svg';
diff --git a/frontend/src/assets/icons/whiteboard/text/italicIcon.svg b/frontend/src/assets/icons/whiteboard/text/italicIcon.svg
new file mode 100644
index 000000000..cc0eab28d
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/italicIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/linkIcon.svg b/frontend/src/assets/icons/whiteboard/text/linkIcon.svg
new file mode 100644
index 000000000..41e1c4551
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/linkIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/orderedListIcon.svg b/frontend/src/assets/icons/whiteboard/text/orderedListIcon.svg
new file mode 100644
index 000000000..307ec4101
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/orderedListIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/strikeThroughIcon.svg b/frontend/src/assets/icons/whiteboard/text/strikeThroughIcon.svg
new file mode 100644
index 000000000..c0c82f3ef
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/strikeThroughIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/textBoxIcon.svg b/frontend/src/assets/icons/whiteboard/text/textBoxIcon.svg
new file mode 100644
index 000000000..2e498b5da
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/textBoxIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/textColorIcon.svg b/frontend/src/assets/icons/whiteboard/text/textColorIcon.svg
new file mode 100644
index 000000000..a50e48935
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/textColorIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/underlineIcon.svg b/frontend/src/assets/icons/whiteboard/text/underlineIcon.svg
new file mode 100644
index 000000000..4c73323f5
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/underlineIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/whiteboard/text/unorderedListIcon.svg b/frontend/src/assets/icons/whiteboard/text/unorderedListIcon.svg
new file mode 100644
index 000000000..e394b4220
--- /dev/null
+++ b/frontend/src/assets/icons/whiteboard/text/unorderedListIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/sample.jpg b/frontend/src/assets/sample.jpg
deleted file mode 100644
index f394c3d39..000000000
Binary files a/frontend/src/assets/sample.jpg and /dev/null differ
diff --git a/frontend/src/components/layout/.gitkeep b/frontend/src/components/.gitkeep
similarity index 100%
rename from frontend/src/components/layout/.gitkeep
rename to frontend/src/components/.gitkeep
diff --git a/frontend/src/components/card/AddReactionBtn.tsx b/frontend/src/components/card/AddReactionBtn.tsx
deleted file mode 100644
index d4ac5352f..000000000
--- a/frontend/src/components/card/AddReactionBtn.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-'use client';
-
-import AddReactionIcon from '@/components/card/AddReactionIcon';
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-
-export default function AddReactionBtn() {
- const { addingReaction, setAddingReaction } = useCardDetailStore();
-
- const onClick = () => {
- setAddingReaction(!addingReaction);
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/card/AddReactionIcon.tsx b/frontend/src/components/card/AddReactionIcon.tsx
deleted file mode 100644
index aa65efead..000000000
--- a/frontend/src/components/card/AddReactionIcon.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-export default function AddReactionIcon() {
- return (
-
- );
-}
diff --git a/frontend/src/components/card/Card.tsx b/frontend/src/components/card/Card.tsx
deleted file mode 100644
index ca3396c6a..000000000
--- a/frontend/src/components/card/Card.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-import { CardData, ImageItem } from '@/types/workspace';
-import { useEffect, useRef, useState } from 'react';
-import { Image, Layer, Stage, Text } from 'react-konva';
-import useImage from 'use-image';
-
-function CardImage({ item }: { item: ImageItem }) {
- const [img] = useImage(item.src);
-
- return (
-
- );
-}
-
-export default function Card({ initialData }: { initialData: CardData }) {
- const { cardData, setCardData, isLoading } = useCardDetailStore();
- useEffect(() => {
- // 카드 정보 전역 변수 설정
- setCardData(initialData);
- }, [setCardData, initialData]);
-
- const sortedItems = [...cardData.items].sort((a, b) => a.zIndex - b.zIndex);
- const isHorizontal = cardData.workspaceWidth === 1200;
-
- // 현재 화면 크기에 맞게 카드 비율 조정
- const cardRef = useRef
(null);
- const [scale, setScale] = useState(1);
- useEffect(() => {
- const handleResize = () => {
- if (cardRef.current) {
- const containerWidth = cardRef.current.offsetWidth;
- const newScale = containerWidth / cardData.workspaceWidth;
- setScale(newScale);
- }
- };
-
- handleResize();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, [cardData.workspaceWidth]);
-
- return (
- !isLoading && (
-
-
-
- {sortedItems.map((item) => {
- if (item.type === 'image') {
- return ;
- }
-
- if (item.type === 'text') {
- return (
-
- );
- }
-
- return null;
- })}
-
-
-
- )
- );
-}
diff --git a/frontend/src/components/card/CardItem.tsx b/frontend/src/components/card/CardItem.tsx
deleted file mode 100644
index db5cc3685..000000000
--- a/frontend/src/components/card/CardItem.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { motion, MotionValue, useTransform } from 'framer-motion';
-import Image from 'next/image';
-import tarotBackImg from '@/assets/TarotBack.png';
-import sampleImg from '@/assets/sample.jpg';
-import { useEffect, useState } from 'react';
-
-interface CardItemProps {
- index: number;
- total: number;
- rotation: MotionValue;
- isDragging: boolean;
- isHovered: boolean;
- onHoverStart: () => void;
- onHoverEnd: () => void;
- isSelected: boolean;
- onSelect: () => void;
-}
-
-const RADIUS = 1200;
-const ANGLE_GAP = 4;
-
-export default function CardItem({
- index,
- total,
- rotation,
- isDragging,
- isHovered,
- onHoverStart,
- onHoverEnd,
- isSelected,
- onSelect,
-}: CardItemProps) {
- const centerIndex = (total - 1) / 2;
- const offset = index - centerIndex;
-
- // 카드 고유 회전: 회전각도 -> 오프셋 기준으로 좌우대칭
- const baseRotate = offset * ANGLE_GAP;
-
- // 전역 회전 + 카드 회전 합성 (선택됐으면 적용 X)
- const rotate = useTransform(rotation, (r) =>
- isSelected ? 0 : r + baseRotate,
- );
-
- const activeHover = isHovered && !isDragging && !isSelected;
-
- const [centerY, setCenterY] = useState(-RADIUS - 480);
-
- useEffect(() => {
- const calculateCenter = () => {
- const vh = window.innerHeight;
- // 부모 위치와 RADIUS를 고려하여 화면 중앙에 오도록 수치 조정
- setCenterY(-(RADIUS + vh * 0.3));
- };
-
- calculateCenter();
-
- window.addEventListener('resize', calculateCenter);
- return () => window.removeEventListener('resize', calculateCenter);
- }, []);
-
- return (
- {
- if (isDragging || isSelected) return;
- e.stopPropagation();
- onSelect();
- }}
- onHoverStart={onHoverStart}
- onHoverEnd={onHoverEnd}
- animate={
- isSelected
- ? {
- y: [-RADIUS, -RADIUS - 360, centerY, centerY, centerY],
- x: '-50%',
- scale: [1, 1.5, 2.5, 2.5, 3],
- rotate: [0, -90, -90, -90, -90],
- rotateY: [0, 0, 0, 0, 180],
- }
- : {
- y: activeHover ? -RADIUS - 30 : -RADIUS,
- x: '-50%',
- scale: activeHover ? 1.06 : 1,
- }
- }
- transition={
- isSelected
- ? {
- // [시작, 상승중, 중앙도착, 대기, 뒤집기완료]
- times: [0, 0.2, 0.5, 0.8, 1],
- duration: 4,
- ease: 'easeInOut',
- }
- : { type: 'spring', stiffness: 260, damping: 26 }
- }
- style={{
- rotate: isSelected ? -90 : rotate,
- transformOrigin: isSelected ? 'center center' : `10% ${RADIUS}px`,
- zIndex: isSelected ? 50 : 1,
- cursor: isSelected ? 'default' : isDragging ? 'grabbing' : 'grab',
- transformStyle: 'preserve-3d',
- }}
- >
- {/* 카드 */}
-
- {/* 카드 뒷면 (클로비 백카드 부분) */}
-
-
-
-
- {/* 카드 앞면 */}
-
-
-
- );
-}
diff --git a/frontend/src/components/card/CardProfile.tsx b/frontend/src/components/card/CardProfile.tsx
deleted file mode 100644
index 33baae32a..000000000
--- a/frontend/src/components/card/CardProfile.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { DUMMY_USER } from '@/app/card/[id]/dummy';
-import FollowBtn from '@/components/card/FollowBtn';
-import Image from 'next/image';
-import Link from 'next/link';
-
-export default function CardProfile() {
- // API 호출 부분
- const { user_id, nickname, profile_path } = DUMMY_USER;
-
- return (
-
-
-
-
- {nickname}
-
-
-
-
- );
-}
diff --git a/frontend/src/components/card/FollowBtn.tsx b/frontend/src/components/card/FollowBtn.tsx
deleted file mode 100644
index ad2e2104f..000000000
--- a/frontend/src/components/card/FollowBtn.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-import { useState } from 'react';
-
-export default function FollowBtn({ hasFollowed }: { hasFollowed: boolean }) {
- const { cardData } = useCardDetailStore();
- const { id } = cardData;
-
- const [isFollowing, setIsFollowing] = useState(hasFollowed);
- const onFollowClick = () => {
- // cardId로 팔로우 API 호출
-
- setIsFollowing((prev) => !prev);
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/card/LikeBtn.tsx b/frontend/src/components/card/LikeBtn.tsx
deleted file mode 100644
index 03f9c3f25..000000000
--- a/frontend/src/components/card/LikeBtn.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-import Image from 'next/image';
-import { useState } from 'react';
-
-interface LikeBtnProps {
- hasLiked: boolean;
- likeCount: number;
-}
-
-export default function LikeBtn({ hasLiked, likeCount }: LikeBtnProps) {
- const { cardData } = useCardDetailStore();
- const { id } = cardData;
-
- const [isLiked, setIsLiked] = useState(hasLiked);
- const [count, setCount] = useState(likeCount);
- const onLikeClick = () => {
- // cardId로 좋아요 API 호출
-
- setCount((prev) => (isLiked ? prev - 1 : prev + 1));
- setIsLiked((prev) => !prev);
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/card/ReportBtn.tsx b/frontend/src/components/card/ReportBtn.tsx
deleted file mode 100644
index a009c8361..000000000
--- a/frontend/src/components/card/ReportBtn.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-import Image from 'next/image';
-
-export default function ReportBtn() {
- const { cardData } = useCardDetailStore();
- const { id } = cardData;
-
- const onReportClick = () => {
- // cardId로 신고 API 호출
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/card/ShareBtn.tsx b/frontend/src/components/card/ShareBtn.tsx
deleted file mode 100644
index 4ae724b8e..000000000
--- a/frontend/src/components/card/ShareBtn.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-import Image from 'next/image';
-
-export default function ShareBtn() {
- const { cardData } = useCardDetailStore();
- const { id } = cardData;
-
- const onShareClick = () => {
- // cardId로 공유
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/card/SlideGuide.tsx b/frontend/src/components/card/SlideGuide.tsx
deleted file mode 100644
index e8c16aaeb..000000000
--- a/frontend/src/components/card/SlideGuide.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-'use client';
-
-import { motion } from 'framer-motion';
-
-export default function SlideGuide() {
- return (
-
- {/* 왼쪽 화살표 */}
-
- ←
-
-
-
- 슬라이드로 카드 선택
-
-
- {/* 오른쪽 화살표 */}
-
- →
-
-
- );
-}
diff --git a/frontend/src/components/card/ToggleReactionBtn.tsx b/frontend/src/components/card/ToggleReactionBtn.tsx
deleted file mode 100644
index 378d5a4be..000000000
--- a/frontend/src/components/card/ToggleReactionBtn.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client';
-
-import { useCardDetailStore } from '@/store/useCardDetailStore';
-
-export default function ToggleReactionBtn() {
- const { showingReaction, setShowingReaction } = useCardDetailStore();
-
- const onToggleClick = () => {
- setShowingReaction(!showingReaction);
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx
new file mode 100644
index 000000000..82b720cf8
--- /dev/null
+++ b/frontend/src/components/common/Modal.tsx
@@ -0,0 +1,106 @@
+import Portal from '@/components/common/Portal';
+import { useRef } from 'react';
+
+interface ModalProps {
+ title: string;
+ cancelText: string;
+ onCancel: () => void;
+ confirmText?: string;
+ onConfirm?: () => void;
+ isLightMode?: boolean;
+ children?: React.ReactNode;
+
+ // Confirm 버튼을 빨간색으로 변경
+ isWarning?: boolean;
+}
+
+export default function Modal({
+ title,
+ cancelText,
+ onCancel,
+ confirmText,
+ onConfirm,
+ isLightMode,
+ children,
+ isWarning,
+}: ModalProps) {
+ // 마우스 클릭 시작 지점과 종료 시점이 둘 다 배경인지 확인
+ // 모달 안에서 드래그 후 밖으로 나갈 시 모달이 닫히는 현상 방지
+ const onOverlayClick = useRef(false);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ // 클릭된 영역이 배경인지 확인
+ if (e.target === e.currentTarget) {
+ onOverlayClick.current = true;
+ } else {
+ onOverlayClick.current = false;
+ }
+ };
+
+ const handleMouseUp = (e: React.MouseEvent) => {
+ // 배경을 클릭하며 시작 && 마우스가 떨어진 시점의 영역이 배경인지 확인
+ if (onOverlayClick.current && e.target === e.currentTarget) {
+ onCancel();
+ }
+ onOverlayClick.current = false;
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = {
+ dialog: {
+ light: 'bg-white border-neutral-200',
+ dark: 'bg-neutral-600 border-neutral-500',
+ },
+ h2: { light: 'text-neutral-900', dark: 'text-neutral-50' },
+ section: { light: 'text-neutral-600', dark: 'text-neutral-200' },
+ cancelBtn: {
+ light: 'bg-neutral-200 text-neutral-900',
+ dark: 'bg-neutral-500 text-neutral-50',
+ },
+ confirmBtn: {
+ light: 'bg-sky-600 text-neutral-50',
+ dark: 'bg-sky-700 text-neutral-50',
+ },
+};
diff --git a/frontend/src/components/common/Portal.tsx b/frontend/src/components/common/Portal.tsx
new file mode 100644
index 000000000..deed5ec71
--- /dev/null
+++ b/frontend/src/components/common/Portal.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { ReactNode, useSyncExternalStore } from 'react';
+import { createPortal } from 'react-dom';
+
+interface PortalProps {
+ children: ReactNode;
+}
+
+function subscribe() {
+ return () => {};
+}
+
+export default function Portal({ children }: PortalProps) {
+ const isClient = useSyncExternalStore(
+ subscribe,
+ () => true,
+ () => false,
+ );
+
+ if (!isClient) return null;
+
+ const modalRoot = document.getElementById('modal-root');
+ if (!modalRoot) return null;
+
+ return createPortal(children, modalRoot);
+}
diff --git a/frontend/src/components/common/ToastMessage.tsx b/frontend/src/components/common/ToastMessage.tsx
new file mode 100644
index 000000000..2c19ce0bd
--- /dev/null
+++ b/frontend/src/components/common/ToastMessage.tsx
@@ -0,0 +1,15 @@
+import Portal from '@/components/common/Portal';
+
+interface ToastMessageProps {
+ message: string;
+}
+
+export default function ToastMessage({ message }: ToastMessageProps) {
+ return (
+
+
+ {message}
+
+
+ );
+}
diff --git a/frontend/src/components/common/button/Button.tsx b/frontend/src/components/common/button/Button.tsx
new file mode 100644
index 000000000..86a46695e
--- /dev/null
+++ b/frontend/src/components/common/button/Button.tsx
@@ -0,0 +1,59 @@
+import { type PropsWithChildren } from 'react';
+import type { ButtonColor, ButtonProps } from './Button.types';
+import { ButtonShape, ButtonSize } from './Button.types';
+import { cn } from '@/utils/cn';
+
+const style: {
+ base: string;
+ size: Record;
+ shape: Record;
+ color: Record;
+} = {
+ base: 'inline-flex items-center justify-center box-border select-none m-0 p-0 w-fit h-fit cursor-pointer disabled:cursor-default',
+ size: {
+ sm: 'h-full h-auto px-3 px-1.5 text-sm font-bold',
+ lg: 'w-full h-full max-h-[52px] py-4 px-2 text-base font-bold',
+ },
+ shape: {
+ square: 'rounded-lg',
+ rounded: 'rounded-full',
+ },
+ color: {
+ active: 'text-white', // TODO: 버튼 호버 시 활성화되는 스타일
+ primary: 'bg-sky-600 text-white hover:bg-sky-700', // sky
+ secondary: 'bg-sky-700 text-white', // dark-sky
+ outlinePrimary: 'bg-white border border-sky-600 text-sky-600',
+ disabled: 'bg-neutral-500 text-white', // ex. 취소 버튼
+ },
+};
+
+const Button = ({
+ size = 'lg',
+ shape = 'rounded',
+ color = 'primary',
+ className,
+ children,
+ ref,
+ ...rest
+}: PropsWithChildren) => {
+ return (
+
+ );
+};
+
+Button.displayName = 'Button';
+
+export default Button;
diff --git a/frontend/src/components/common/button/Button.types.ts b/frontend/src/components/common/button/Button.types.ts
new file mode 100644
index 000000000..2b0556a9f
--- /dev/null
+++ b/frontend/src/components/common/button/Button.types.ts
@@ -0,0 +1,17 @@
+import type { Ref } from 'react';
+
+export type ButtonSize = 'sm' | 'lg';
+export type ButtonShape = 'square' | 'rounded';
+export type ButtonColor =
+ | 'active'
+ | 'primary'
+ | 'outlinePrimary'
+ | 'secondary'
+ | 'disabled';
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ size?: ButtonSize;
+ shape?: ButtonShape;
+ color?: ButtonColor;
+ ref?: Ref;
+}
diff --git a/frontend/src/components/common/button/index.ts b/frontend/src/components/common/button/index.ts
new file mode 100644
index 000000000..93d32c48d
--- /dev/null
+++ b/frontend/src/components/common/button/index.ts
@@ -0,0 +1,2 @@
+export { default } from './Button';
+export * from './Button.types';
diff --git a/frontend/src/components/meeting/ChatListItem.tsx b/frontend/src/components/meeting/ChatListItem.tsx
new file mode 100644
index 000000000..d792d281f
--- /dev/null
+++ b/frontend/src/components/meeting/ChatListItem.tsx
@@ -0,0 +1,94 @@
+import { DownloadIcon, FileIcon } from '@/assets/icons/meeting';
+import { formatFileSize, formatTimestamp } from '@/utils/formatter';
+import Image from 'next/image';
+
+type TextChat = {
+ type: 'TEXT';
+ text: string;
+};
+
+type ImageChat = {
+ type: 'IMAGE';
+ src: string;
+};
+
+type FileChat = {
+ type: 'FILE';
+ fileName: string;
+ size: number;
+};
+
+type ChatContent = TextChat | ImageChat | FileChat;
+
+interface ChatListItemProps {
+ id: string;
+ name: string;
+ profileImg: string;
+ createdAt: string;
+ content: ChatContent;
+}
+
+export default function ChatListItem({
+ name,
+ profileImg,
+ createdAt,
+ content,
+}: ChatListItemProps) {
+ return (
+
+
+
+
+ {/* 댓글 정보 */}
+
+ {name}
+
+ {formatTimestamp(createdAt)}
+
+
+
+ {/* 댓글 내용 */}
+ {content.type === 'TEXT' && (
+
+ {content.text}
+
+ )}
+ {content.type === 'IMAGE' && (
+
+
+
+ )}
+ {content.type === 'FILE' && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/ChatModal.tsx b/frontend/src/components/meeting/ChatModal.tsx
new file mode 100644
index 000000000..078ce5064
--- /dev/null
+++ b/frontend/src/components/meeting/ChatModal.tsx
@@ -0,0 +1,91 @@
+import { DUMMY_CHATS } from '@/app/[meetingId]/dummy';
+import { CloseIcon, ImageIcon } from '@/assets/icons/common';
+import { FileIcon, SendIcon } from '@/assets/icons/meeting';
+import ChatListItem from '@/components/meeting/ChatListItem';
+import { useMeeingStore } from '@/store/useMeetingStore';
+import { MouseEvent, useState } from 'react';
+
+export default function ChatModal() {
+ const chats = DUMMY_CHATS;
+
+ const { setIsOpen } = useMeeingStore();
+ const [value, setValue] = useState('');
+ const [files, setFiles] = useState([]);
+
+ const onCloseClick = () => setIsOpen('isChatOpen', false);
+
+ // 이후 form 관련 라이브러리 사용 시 수정 필요
+ const onSubmit = (e: MouseEvent) => {
+ e.preventDefault();
+ if (value.trim().length === 0) return;
+
+ console.log(value);
+ setValue('');
+ };
+
+ // Enter 시 Submit
+ // Shift + Enter 시 줄바꿈
+ // textarea 자동 높이 조절 추가하면 좋을 것 같아요
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/meeting/DeviceDropdown.tsx b/frontend/src/components/meeting/DeviceDropdown.tsx
new file mode 100644
index 000000000..c563fc628
--- /dev/null
+++ b/frontend/src/components/meeting/DeviceDropdown.tsx
@@ -0,0 +1,62 @@
+import { ArrowDownIcon } from '@/assets/icons/common';
+import { useState } from 'react';
+
+interface Props {
+ label: string;
+ devices: MediaDeviceInfo[];
+ icon: React.ComponentType<{ className?: string }>;
+ selectedId: string;
+ onSelect: (id: string) => void;
+ className?: string;
+}
+
+export function DeviceDropdown({
+ label,
+ devices,
+ icon: Icon,
+ selectedId,
+ onSelect,
+ className,
+}: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const selected = devices.find((d) => d.deviceId === selectedId);
+ const isDisabled = devices.length === 0;
+
+ return (
+
+
+
+ {!isDisabled && isOpen && (
+
+ {devices.map((device) => (
+ - {
+ onSelect(device.deviceId);
+ setIsOpen(false);
+ }}
+ className="cursor-pointer px-3 py-2 text-sm first:rounded-t-sm last:rounded-b-sm hover:bg-neutral-100"
+ >
+ {device.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/meeting/InfoModal.tsx b/frontend/src/components/meeting/InfoModal.tsx
new file mode 100644
index 000000000..9fb336a7f
--- /dev/null
+++ b/frontend/src/components/meeting/InfoModal.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import { DUMMY_MEETING_INFO } from '@/app/[meetingId]/dummy';
+import {
+ CopyIcon,
+ EditIcon,
+ EyeOffIcon,
+ EyeOnIcon,
+} from '@/assets/icons/common';
+import Modal from '@/components/common/Modal';
+import ToastMessage from '@/components/common/ToastMessage';
+import { useMeeingStore } from '@/store/useMeetingStore';
+import { hidePassword } from '@/utils/security';
+import { MouseEvent, useEffect, useState } from 'react';
+
+export default function InfoModal() {
+ const { id, host, password } = DUMMY_MEETING_INFO;
+
+ const { setIsOpen } = useMeeingStore();
+ const [currentPassword, setCurrentPassword] = useState(password || '');
+ const [currentModal, setCurrentModal] = useState<'INFO' | 'PASSWORD'>('INFO');
+ const [isPasswordHidden, setIsPasswordHidden] = useState(true);
+ const [value, setValue] = useState('');
+ const [hasCopied, setHasCopied] = useState(false);
+
+ const onModalClose = () => setIsOpen('isInfoOpen', false);
+
+ const onCodeCopyClick = () => {
+ navigator.clipboard.writeText(id);
+ setHasCopied(true);
+ };
+
+ // 클립보드 복사 시 토스트 메세지 1.5초간 표시
+ useEffect(() => {
+ if (!hasCopied) return;
+ const timer = setTimeout(() => setHasCopied(false), 1500);
+
+ return () => clearTimeout(timer);
+ }, [hasCopied]);
+
+ const onPasswordHideClick = (e: MouseEvent) => {
+ e.stopPropagation();
+ setIsPasswordHidden((prev) => !prev);
+ };
+
+ const onPasswordChangeClick = () => {
+ setValue(currentPassword);
+ setCurrentModal((prev) => (prev === 'PASSWORD' ? 'INFO' : 'PASSWORD'));
+ };
+
+ const onPasswordConfirm = (password: string) => {
+ setCurrentPassword(password);
+ setIsPasswordHidden(true);
+ setCurrentModal('INFO');
+ };
+
+ return (
+ <>
+ {currentModal === 'INFO' && (
+
+
+
+ {hasCopied && (
+
+ )}
+
+ )}
+
+ {currentModal === 'PASSWORD' && (
+ onPasswordConfirm(value)}
+ >
+ setValue(e.target.value)}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/meeting/MeetingButton.tsx b/frontend/src/components/meeting/MeetingButton.tsx
new file mode 100644
index 000000000..b404efc65
--- /dev/null
+++ b/frontend/src/components/meeting/MeetingButton.tsx
@@ -0,0 +1,24 @@
+import { ButtonHTMLAttributes, ReactNode } from 'react';
+
+interface MeetingButtonProps extends ButtonHTMLAttributes {
+ icon: ReactNode;
+ text: string;
+ isActive?: boolean;
+}
+
+export default function MeetingButton({
+ icon,
+ text,
+ isActive,
+ ...props
+}: MeetingButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/meeting/MeetingLobby.tsx b/frontend/src/components/meeting/MeetingLobby.tsx
new file mode 100644
index 000000000..152eed255
--- /dev/null
+++ b/frontend/src/components/meeting/MeetingLobby.tsx
@@ -0,0 +1,80 @@
+import { useMediaDevices } from '@/hooks/useMediaDevices';
+import Button from '../common/button';
+import { DeviceDropdown } from './DeviceDropdown';
+import { MediaPreview } from './media/MediaPreview';
+import { CamOnIcon, MicOnIcon, VolumnIcon } from '@/assets/icons/meeting';
+
+export default function MeetingLobby({
+ meetingId,
+ onJoin,
+}: {
+ meetingId: string;
+ onJoin: () => void;
+}) {
+ const meetingLeader = 'Tony';
+ const meetingMemberCnt = 9;
+
+ const {
+ microphones,
+ cameras,
+ speakers,
+ micId,
+ cameraId,
+ speakerId,
+ setMicId,
+ setCameraId,
+ setSpeakerId,
+ } = useMediaDevices();
+
+ return (
+
+ {/* 영상, 마이크 설정 부분 */}
+
+
+ {/* 회의 참여 부분 */}
+
+
+ {meetingLeader} 님의 회의실
+
+
+ 현재 참여자: {meetingMemberCnt}명
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/MeetingMenu.tsx b/frontend/src/components/meeting/MeetingMenu.tsx
new file mode 100644
index 000000000..10bda6096
--- /dev/null
+++ b/frontend/src/components/meeting/MeetingMenu.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import {
+ CamOffIcon,
+ CamOnIcon,
+ ChatIcon,
+ CodeIcon,
+ ExitMeetingIcon,
+ InfoIcon,
+ MarkedChatIcon,
+ MemberIcon,
+ MicOffIcon,
+ MicOnIcon,
+ ShareIcon,
+ WorkspaceIcon,
+} from '@/assets/icons/meeting';
+import Modal from '@/components/common/Modal';
+import MeetingButton from '@/components/meeting/MeetingButton';
+import { useMeeingStore } from '@/store/useMeetingStore';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+export default function MeetingMenu() {
+ const {
+ audio,
+ setAudio,
+ video,
+ setVideo,
+ members,
+ hasNewChat,
+ setHasNewChat,
+ isInfoOpen,
+ isChatOpen,
+ isMemberOpen,
+ isWorkspaceOpen,
+ isCodeEditorOpen,
+ setIsOpen,
+ } = useMeeingStore();
+
+ const toggleAudio = () => setAudio(audio === 'ON' ? 'OFF' : 'ON');
+
+ const toggleVideo = () => setVideo(video === 'ON' ? 'OFF' : 'ON');
+
+ const onInfoClick = () => {
+ setIsOpen('isInfoOpen', !isInfoOpen);
+ };
+
+ const onMemberClick = () => {
+ setIsOpen('isMemberOpen', !isMemberOpen);
+ };
+
+ const onChatClick = () => {
+ setHasNewChat(false);
+ setIsOpen('isChatOpen', !isChatOpen);
+ };
+
+ const onWorkspaceClick = () => {
+ setIsOpen('isWorkspaceOpen', !isWorkspaceOpen);
+ };
+
+ const onCodeEditorClick = () => {
+ setIsOpen('isCodeEditorOpen', !isCodeEditorOpen);
+ };
+
+ const router = useRouter();
+ const [isExitModalOpen, setIsExitModalOpen] = useState(false);
+ const toggleExitModal = () => setIsExitModalOpen((prev) => !prev);
+ const onExit = () => router.replace('/');
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/meeting/MeetingRoom.tsx b/frontend/src/components/meeting/MeetingRoom.tsx
new file mode 100644
index 000000000..f323e9bf0
--- /dev/null
+++ b/frontend/src/components/meeting/MeetingRoom.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import ChatModal from '@/components/meeting/ChatModal';
+import InfoModal from '@/components/meeting/InfoModal';
+import MeetingMenu from '@/components/meeting/MeetingMenu';
+import MemberModal from '@/components/meeting/MemberModal';
+import MemberVideoBar from '@/components/meeting/MemberVideoBar';
+import Whiteboard from '@/components/whiteboard/Whiteboard';
+import { useMeeingStore } from '@/store/useMeetingStore';
+
+export default function MeetingRoom({ meetingId }: { meetingId: string }) {
+ const { isInfoOpen, isMemberOpen, isChatOpen, isWorkspaceOpen } =
+ useMeeingStore();
+
+ return (
+
+
+
+
+ {/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */}
+ {isWorkspaceOpen && (
+
+ )}
+
+ {/* 참가자 / 채팅창 */}
+ {(isMemberOpen || isChatOpen) && (
+
+ {isMemberOpen && }
+ {isChatOpen && }
+
+ )}
+
+
+
+
+ {isInfoOpen && }
+
+ );
+}
diff --git a/frontend/src/components/meeting/MemberListItem.tsx b/frontend/src/components/meeting/MemberListItem.tsx
new file mode 100644
index 000000000..8bda2d1e9
--- /dev/null
+++ b/frontend/src/components/meeting/MemberListItem.tsx
@@ -0,0 +1,84 @@
+import { MoreVertIcon } from '@/assets/icons/common';
+import {
+ CamOffIcon,
+ CamOnIcon,
+ MicOffIcon,
+ MicOnIcon,
+} from '@/assets/icons/meeting';
+import Image from 'next/image';
+import { useState } from 'react';
+
+interface MemberListItemProps {
+ id: string;
+ name: string;
+ audio: boolean;
+ video: boolean;
+ profileImg: string;
+ reverseDropdown?: boolean;
+}
+
+export default function MemberListItem({
+ name,
+ audio,
+ video,
+ profileImg,
+ reverseDropdown,
+}: MemberListItemProps) {
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ const onMoreClick = () => setIsDropdownOpen((prev) => !prev);
+
+ return (
+
+ {/* 참가자 정보 */}
+
+
+ {name}
+
+
+ {/* 참가자 상태 */}
+
+ {audio ? (
+
+ ) : (
+
+ )}
+ {video ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 더보기 버튼 */}
+
+
+
+ {isDropdownOpen && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/MemberModal.tsx b/frontend/src/components/meeting/MemberModal.tsx
new file mode 100644
index 000000000..788f16a50
--- /dev/null
+++ b/frontend/src/components/meeting/MemberModal.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { DUMMY_MEMBERS } from '@/app/[meetingId]/dummy';
+import { CloseIcon } from '@/assets/icons/common';
+import MemberListItem from '@/components/meeting/MemberListItem';
+import { useMeeingStore } from '@/store/useMeetingStore';
+
+export default function MemberModal() {
+ // 이후 API로 수정 필요
+ const members = DUMMY_MEMBERS;
+
+ const { setIsOpen } = useMeeingStore();
+
+ const onCloseClick = () => setIsOpen('isMemberOpen', false);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/meeting/MemberVideoBar.tsx b/frontend/src/components/meeting/MemberVideoBar.tsx
new file mode 100644
index 000000000..0be94a996
--- /dev/null
+++ b/frontend/src/components/meeting/MemberVideoBar.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { DUMMY_DATA } from '@/app/[meetingId]/dummy';
+import { ChevronLeftIcon, ChevronRightIcon } from '@/assets/icons/common';
+import SmVideo from '@/components/meeting/SmVideo';
+import { useMeeingStore } from '@/store/useMeetingStore';
+import { useEffect, useState } from 'react';
+
+export default function MemberVideoBar() {
+ // 이후 WebRTC로 수정 필요
+ const { lastPage, membersPerPage, totalMemberCount, members } = DUMMY_DATA;
+
+ const { setMembers } = useMeeingStore();
+ const [currentPage, setCurrentPage] = useState(1);
+ const [hasPrevPage, hasNextPage] = [currentPage > 1, currentPage < lastPage];
+
+ useEffect(() => {
+ setMembers(totalMemberCount);
+ }, [setMembers, totalMemberCount]);
+
+ const onPrevClick = () => {
+ if (!hasPrevPage) return;
+ setCurrentPage((prev) => prev - 1);
+ };
+
+ const onNextClick = () => {
+ if (!hasNextPage) return;
+ setCurrentPage((prev) => prev + 1);
+ };
+
+ // (프로토타입용) 이후 WebRTC나 API 호출 시 불필요
+ const start = (currentPage - 1) * membersPerPage;
+ const end = (currentPage - 1) * membersPerPage + membersPerPage;
+
+ return (
+
+
+
+
+ {/* 이후 백엔드 연동 시 pagination으로 수정, 수동 slice는 불필요 */}
+ {members.slice(start, end).map((member) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/SmVideo.tsx b/frontend/src/components/meeting/SmVideo.tsx
new file mode 100644
index 000000000..de22a9e36
--- /dev/null
+++ b/frontend/src/components/meeting/SmVideo.tsx
@@ -0,0 +1,74 @@
+import { MoreHoriIcon } from '@/assets/icons/common';
+import { MicOffIcon } from '@/assets/icons/meeting';
+import Image from 'next/image';
+import { useState } from 'react';
+
+interface SmVideoProps {
+ name: string;
+ audio: boolean;
+ video: boolean;
+ speaking: boolean;
+ profileImg: string;
+
+ // 이후 음성이나 영상 정보 추가 필요
+}
+
+export default function SmVideo({
+ name,
+ audio,
+ video,
+ speaking,
+ profileImg,
+}: SmVideoProps) {
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const onMoreClick = () => setIsDropdownOpen((prev) => !prev);
+
+ return (
+
+ {/* 영상 */}
+ {video ? (
+
+ ) : (
+
+ )}
+
+ {/* 이름표 */}
+
+ {!audio && }
+
+ {name}
+
+
+
+ {/* 더보기 메뉴 */}
+
+
+ {isDropdownOpen && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/media/MediaPreview.tsx b/frontend/src/components/meeting/media/MediaPreview.tsx
new file mode 100644
index 000000000..9b42a4359
--- /dev/null
+++ b/frontend/src/components/meeting/media/MediaPreview.tsx
@@ -0,0 +1,75 @@
+import {
+ CamOffIcon,
+ CamOnIcon,
+ MicOffIcon,
+ MicOnIcon,
+} from '@/assets/icons/meeting';
+import Button from '@/components/common/button';
+import VideoView from './VideoView';
+import { useMediaPreview } from '@/hooks/useMediaPreview';
+
+export function MediaPreview() {
+ const { media, stream, canRenderVideo, toggleAudio, toggleVideo } =
+ useMediaPreview();
+
+ return (
+
+ {/* Video Layer */}
+ {canRenderVideo && stream &&
}
+
+ {/* Placeholder Layer */}
+ {!canRenderVideo && (
+
+
+ {media.cameraPermission === 'denied' ? (
+ <>
+ 브라우저 주소창의 자물쇠 아이콘을 눌러
+
+ 카메라와 마이크 권한을 허용해 주세요.
+ >
+ ) : (
+ <>
+ 마이크와 카메라를 사용하려면
+
+ 접근 권한이 필요해요
+ >
+ )}
+
+
+ {(media.cameraPermission === 'unknown' ||
+ media.micPermission === 'unknown') && (
+
+ )}
+
+ )}
+
+ {/* Control Layer */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/meeting/media/VideoView.tsx b/frontend/src/components/meeting/media/VideoView.tsx
new file mode 100644
index 000000000..26a01b39c
--- /dev/null
+++ b/frontend/src/components/meeting/media/VideoView.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+interface VideoViewProps {
+ stream: MediaStream;
+ muted?: boolean;
+ mirrored?: boolean;
+}
+
+export default function VideoView({
+ stream,
+ muted = true,
+ mirrored = true,
+}: VideoViewProps) {
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ video.srcObject = stream;
+
+ // Safari와 iOS 대응
+ video.onloadedmetadata = () => {
+ video.play().catch(() => {
+ // autoplay 정책 실패 시 무시
+ });
+ };
+
+ return () => {
+ video.pause();
+ video.srcObject = null;
+ };
+ }, [stream]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/Canvas.tsx b/frontend/src/components/whiteboard/Canvas.tsx
new file mode 100644
index 000000000..d7ae8a233
--- /dev/null
+++ b/frontend/src/components/whiteboard/Canvas.tsx
@@ -0,0 +1,219 @@
+'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(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,
+ ) => {
+ 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,
+ ) => {
+ updateItem(id, newAttributes);
+ };
+
+ if (size.width === 0 || size.height === 0) return null;
+
+ return (
+
+
+
+ {/* Canvas 경계 */}
+
+
+ {/* 아이템 렌더링 */}
+ {items.map((item) => (
+
+ handleItemChange(item.id, newAttributes)
+ }
+ onArrowDblClick={handleArrowDblClick}
+ onDragStart={() => {
+ if (item.type === 'arrow') {
+ setIsDraggingArrow(true);
+ }
+ }}
+ onDragEnd={() => {
+ if (item.type === 'arrow') {
+ setIsDraggingArrow(false);
+ }
+ }}
+ />
+ ))}
+
+ {/* 화살표 핸들 (드래그 중이 아닐 때만) */}
+ {isArrowSelected && selectedItem && !isDraggingArrow && (
+
+ )}
+
+ {/* Transformer */}
+
+
+
+
+ {/* 텍스트 편집 모드 */}
+ {editingTextId && editingItem && (
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/Whiteboard.tsx b/frontend/src/components/whiteboard/Whiteboard.tsx
new file mode 100644
index 000000000..242b07e16
--- /dev/null
+++ b/frontend/src/components/whiteboard/Whiteboard.tsx
@@ -0,0 +1,29 @@
+'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: () => (
+
+ ),
+});
+
+export default function Whiteboard() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/common/NavButton.tsx b/frontend/src/components/whiteboard/common/NavButton.tsx
new file mode 100644
index 000000000..fa6499a5a
--- /dev/null
+++ b/frontend/src/components/whiteboard/common/NavButton.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { ComponentType, SVGProps } from 'react';
+
+interface NavButtonProps {
+ icon: ComponentType>;
+ label: string;
+ bgColor?: string;
+ activeBgColor?: string;
+ isActive?: boolean;
+ onClick?: (e: React.MouseEvent) => void;
+}
+
+export default function NavButton({
+ icon: Icon,
+ label,
+ bgColor,
+ activeBgColor,
+ isActive,
+ onClick,
+}: NavButtonProps) {
+ const baseBg = bgColor ?? 'bg-neutral-800';
+ const hoverBg = bgColor ? `hover:${bgColor}` : 'hover:bg-neutral-700';
+ const activeStyle = activeBgColor ?? 'bg-sky-700';
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/constants/canvas.ts b/frontend/src/components/whiteboard/constants/canvas.ts
new file mode 100644
index 000000000..3820b8a0a
--- /dev/null
+++ b/frontend/src/components/whiteboard/constants/canvas.ts
@@ -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;
diff --git a/frontend/src/components/whiteboard/controls/HistoryControl.tsx b/frontend/src/components/whiteboard/controls/HistoryControl.tsx
new file mode 100644
index 000000000..3f6ff95f3
--- /dev/null
+++ b/frontend/src/components/whiteboard/controls/HistoryControl.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import NavButton from '@/components/whiteboard/common/NavButton';
+
+import { UndoIcon, RedoIcon } from '@/assets/icons/whiteboard';
+
+export default function HistoryControls() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/controls/ItemTransformer.tsx b/frontend/src/components/whiteboard/controls/ItemTransformer.tsx
new file mode 100644
index 000000000..6aa019c0a
--- /dev/null
+++ b/frontend/src/components/whiteboard/controls/ItemTransformer.tsx
@@ -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;
+}
+
+export default function ItemTransformer({
+ selectedId,
+ items,
+ stageRef,
+}: ItemTransformerProps) {
+ const transformerRef = useRef(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([]);
+ }
+ }
+ }, [selectedId, items, stageRef, isArrowSelected]);
+
+ return (
+ {
+ 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);
+ }
+ }
+ }}
+ />
+ );
+}
diff --git a/frontend/src/components/whiteboard/controls/OverlayControl.tsx b/frontend/src/components/whiteboard/controls/OverlayControl.tsx
new file mode 100644
index 000000000..c2c516c69
--- /dev/null
+++ b/frontend/src/components/whiteboard/controls/OverlayControl.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import NavButton from '@/components/whiteboard/common/NavButton';
+
+import { ShareIcon, CloseIcon } from '@/assets/icons/common';
+
+export default function OverlayControls() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/controls/ZoomControl.tsx b/frontend/src/components/whiteboard/controls/ZoomControl.tsx
new file mode 100644
index 000000000..d86bb3686
--- /dev/null
+++ b/frontend/src/components/whiteboard/controls/ZoomControl.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import NavButton from '../common/NavButton';
+import { ZoomOutIcon, ZoomInIcon } from '@/assets/icons/whiteboard';
+import { useCanvasStore } from '@/store/useCanvasStore';
+import {
+ MIN_SCALE,
+ MAX_SCALE,
+ ZOOM_STEP,
+} from '@/components/whiteboard/constants/canvas';
+
+export default function ZoomControls() {
+ const stageScale = useCanvasStore((state) => state.stageScale);
+ const stagePos = useCanvasStore((state) => state.stagePos);
+ const setStageScale = useCanvasStore((state) => state.setStageScale);
+ const setStagePos = useCanvasStore((state) => state.setStagePos);
+
+ // 줌 인
+ const handleZoomIn = () => {
+ const newScale = Math.min(stageScale + ZOOM_STEP, MAX_SCALE);
+ zoomToCenter(newScale);
+ };
+
+ // 줌 아웃
+ const handleZoomOut = () => {
+ const newScale = Math.max(stageScale - ZOOM_STEP, MIN_SCALE);
+ zoomToCenter(newScale);
+ };
+
+ // 화면 중앙을 기준으로 줌
+ const zoomToCenter = (newScale: number) => {
+ if (newScale === stageScale) return;
+
+ const centerX = window.innerWidth / 2;
+ const centerY = window.innerHeight / 2;
+
+ // 현재 화면 중앙 캔버스 좌표 계산
+ const pointTo = {
+ x: (centerX - stagePos.x) / stageScale,
+ y: (centerY - stagePos.y) / stageScale,
+ };
+
+ // 확대, 축소 후에도 같은 캔버스 좌표가 중앙에 오도록
+ const newPos = {
+ x: centerX - pointTo.x * newScale,
+ y: centerY - pointTo.y * newScale,
+ };
+
+ setStageScale(newScale);
+ setStagePos(newPos);
+ };
+
+ const zoomPercentage = Math.round(stageScale * 100);
+
+ return (
+
+
+
+
+
+ {zoomPercentage}%
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/whiteboard/items/RenderItem.tsx b/frontend/src/components/whiteboard/items/RenderItem.tsx
new file mode 100644
index 000000000..d6fe483ed
--- /dev/null
+++ b/frontend/src/components/whiteboard/items/RenderItem.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { Text, Arrow } from 'react-konva';
+import { useCanvasStore } from '@/store/useCanvasStore';
+import type {
+ TextItem,
+ ArrowItem,
+ WhiteboardItem,
+} from '@/types/whiteboard';
+
+interface RenderItemProps {
+ item: WhiteboardItem;
+ isSelected: boolean;
+ onSelect: (id: string) => void;
+ onChange: (newAttributes: Partial) => void;
+ onDragStart?: () => void;
+ onDragEnd?: () => void;
+ onArrowDblClick?: (id: string) => void;
+}
+
+export default function RenderItem({
+ item,
+ onSelect,
+ onChange,
+ onDragStart,
+ onDragEnd,
+ onArrowDblClick,
+}: RenderItemProps) {
+ const setEditingTextId = useCanvasStore((state) => state.setEditingTextId);
+
+ // 텍스트 렌더링
+ if (item.type === 'text') {
+ const textItem = item as TextItem;
+ return (
+ onSelect(item.id)}
+ onTouchStart={() => onSelect(item.id)}
+ onMouseEnter={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) {
+ container.style.cursor = 'move';
+ }
+ }}
+ onMouseLeave={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) {
+ container.style.cursor = 'default';
+ }
+ }}
+ onDblClick={() => {
+ setEditingTextId(item.id);
+ onSelect(item.id);
+ }}
+ onDragEnd={(e) => {
+ onChange({
+ x: e.target.x(),
+ y: e.target.y(),
+ });
+ }}
+ onTransformEnd={(e) => {
+ const node = e.target;
+ const scaleX = node.scaleX();
+
+ node.scaleX(1);
+ node.scaleY(1);
+
+ onChange({
+ x: node.x(),
+ y: node.y(),
+ width: Math.max(5, node.width() * scaleX),
+ rotation: node.rotation(),
+ });
+ }}
+ />
+ );
+ }
+
+ // 화살표 렌더링
+ if (item.type === 'arrow') {
+ const arrowItem = item as ArrowItem;
+ return (
+ onSelect(item.id)}
+ onMouseEnter={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) {
+ container.style.cursor = 'move';
+ }
+ }}
+ onMouseLeave={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) {
+ container.style.cursor = 'default';
+ }
+ }}
+ onDblClick={() => {
+ onArrowDblClick?.(item.id);
+ }}
+ onDragStart={() => {
+ onDragStart?.();
+ }}
+ onDragEnd={(e) => {
+ const pos = e.target.position();
+ const newPoints = arrowItem.points.map((p, i) =>
+ i % 2 === 0 ? p + pos.x : p + pos.y
+ );
+
+ e.target.position({ x: 0, y: 0 });
+
+ onChange({
+ points: newPoints,
+ });
+
+ onDragEnd?.();
+ }}
+ />
+ );
+ }
+
+ return null;
+}
diff --git a/frontend/src/components/whiteboard/items/arrow/ArrowHandles.tsx b/frontend/src/components/whiteboard/items/arrow/ArrowHandles.tsx
new file mode 100644
index 000000000..44b4985f5
--- /dev/null
+++ b/frontend/src/components/whiteboard/items/arrow/ArrowHandles.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { Circle } from 'react-konva';
+import { KonvaEventObject } from 'konva/lib/Node';
+import { getControlPoints } from '@/utils/arrow';
+import type { ArrowItem } from '@/types/whiteboard';
+
+interface ArrowHandlesProps {
+ arrow: ArrowItem;
+ selectedHandleIndex: number | null;
+ onHandleClick: (e: KonvaEventObject, index: number) => void;
+ onStartDrag: (e: KonvaEventObject) => void;
+ onControlPointDrag: (pointIndex: number, e: KonvaEventObject) => void;
+ onEndDrag: (e: KonvaEventObject) => void;
+}
+
+export default function ArrowHandles({
+ arrow,
+ selectedHandleIndex,
+ onHandleClick,
+ onStartDrag,
+ onControlPointDrag,
+ onEndDrag,
+}: ArrowHandlesProps) {
+ const controlPoints = getControlPoints(arrow.points);
+ const startPoint = { x: arrow.points[0], y: arrow.points[1] };
+ const endPoint = {
+ x: arrow.points[arrow.points.length - 2],
+ y: arrow.points[arrow.points.length - 1],
+ };
+
+ return (
+ <>
+ {/* 시작 핸들 */}
+ onHandleClick(e, 0)}
+ />
+
+ {/* 중간점 핸들 */}
+ {controlPoints.map((point, idx) => {
+ const isHandleSelected = selectedHandleIndex === point.index;
+ return (
+ onControlPointDrag(point.index, e)}
+ onClick={(e) => onHandleClick(e, point.index)}
+ />
+ );
+ })}
+
+ {/* 끝점 핸들 */}
+ onHandleClick(e, arrow.points.length - 2)}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/workspace/text/TextArea.tsx b/frontend/src/components/whiteboard/items/text/TextArea.tsx
similarity index 57%
rename from frontend/src/components/workspace/text/TextArea.tsx
rename to frontend/src/components/whiteboard/items/text/TextArea.tsx
index 9a6840e15..56889e008 100644
--- a/frontend/src/components/workspace/text/TextArea.tsx
+++ b/frontend/src/components/whiteboard/items/text/TextArea.tsx
@@ -3,21 +3,50 @@
import { useEffect, useRef } from 'react';
import Konva from 'konva';
-interface TextAreaProps {
- textNode: Konva.Text;
+import type { TextItem } from '@/types/whiteboard';
+
+export interface TextAreaProps {
+ textId: string;
+ textItem: TextItem;
+ stageRef: React.RefObject;
onChange: (v: string) => void;
onClose: () => void;
+ onBoundsChange?: (width: number, height: number) => void;
}
export default function TextArea({
- textNode,
+ textId,
+ textItem,
+ stageRef,
onChange,
onClose,
+ onBoundsChange,
}: TextAreaProps) {
const ref = useRef(null);
+ const lastBoundsRef = useRef<{ width: number; height: number }>({
+ width: 0,
+ height: 0,
+ });
useEffect(() => {
- if (!ref.current) return;
+ if (!stageRef.current) return;
+
+ const textNode = stageRef.current.findOne('#' + textId) as Konva.Text;
+ if (!textNode) return;
+
+ textNode.visible(false);
+ return () => {
+ textNode.visible(true);
+ textNode.getLayer()?.batchDraw();
+ };
+ }, [textId, stageRef]);
+
+ useEffect(() => {
+ if (!ref.current || !stageRef.current) return;
+
+ const textNode = stageRef.current.findOne('#' + textId) as Konva.Text;
+ if (!textNode) return;
+
const textarea = ref.current;
//textNode와 스타일 동기화
@@ -28,11 +57,26 @@ export default function TextArea({
textarea.value = textNode.text();
textarea.style.position = 'absolute';
- textarea.style.left = `${absPos.x}px`;
- textarea.style.top = `${absPos.y + 1}px`;
// 크기
- textarea.style.width = `${textNode.width() * stageScale}px`;
+ const nodeWidth = textNode.width() * stageScale;
+ const nodeHeight = textNode.height() * stageScale;
+ const keepCentered = Boolean(textItem.parentPolygonId);
+
+ const positionTextarea = () => {
+ if (keepCentered) {
+ const centerX = absPos.x + nodeWidth / 2;
+ const centerY = absPos.y + nodeHeight / 2;
+ const currentHeight = textarea.offsetHeight;
+ textarea.style.left = `${centerX - nodeWidth / 2}px`;
+ textarea.style.top = `${centerY - currentHeight / 2}px`;
+ } else {
+ textarea.style.left = `${absPos.x}px`;
+ textarea.style.top = `${absPos.y}px`;
+ }
+ };
+
+ textarea.style.width = `${nodeWidth}px`;
// 폰트 스타일
textarea.style.fontSize = `${textNode.fontSize() * stageScale}px`;
@@ -70,6 +114,29 @@ export default function TextArea({
textarea.scrollHeight + textNode.fontSize() * stageScale
}px`;
+ positionTextarea();
+
+ const notifyBounds = () => {
+ if (!keepCentered || !onBoundsChange) return;
+
+ const canvasWidth = nodeWidth / stageScale;
+ const canvasHeight = textarea.offsetHeight / stageScale;
+
+ const hasWidthChanged = Math.abs(
+ canvasWidth - lastBoundsRef.current.width,
+ ) > 0.5;
+ const hasHeightChanged = Math.abs(
+ canvasHeight - lastBoundsRef.current.height,
+ ) > 0.5;
+
+ if (!hasWidthChanged && !hasHeightChanged) return;
+
+ lastBoundsRef.current = { width: canvasWidth, height: canvasHeight };
+ onBoundsChange(canvasWidth, canvasHeight);
+ };
+
+ notifyBounds();
+
textarea.focus();
// 이벤트 핸들러
@@ -83,9 +150,13 @@ export default function TextArea({
const handleInput = () => {
textarea.style.height = 'auto';
- textarea.style.height = `${textarea.scrollHeight}px`;
+ textarea.style.height = `${
+ textarea.scrollHeight + textNode.fontSize() * stageScale
+ }px`;
onChange(textarea.value);
+ positionTextarea();
+ notifyBounds();
};
const handleOutsideClick = (e: MouseEvent) => {
@@ -100,7 +171,6 @@ export default function TextArea({
// 외부 영역 클릭 시 textarea가 즉시 blur 되면서 onChange가 누락되는 문제가 있어
// 이벤트 등록 시점을 다음 이벤트 루프로 미룸
- // 캡처링이나 textarea blur, React Portal 활용도 가능
const timer = setTimeout(() => {
window.addEventListener('mousedown', handleOutsideClick);
});
@@ -111,7 +181,7 @@ export default function TextArea({
window.removeEventListener('mousedown', handleOutsideClick);
clearTimeout(timer);
};
- }, [textNode, onChange, onClose]);
+ }, [textId, textItem, onChange, onClose, onBoundsChange, stageRef]);
return (