From abbbe331d889becbe12f9b5d66efc46c327b484f Mon Sep 17 00:00:00 2001 From: Hogyun Jeon Date: Mon, 11 Nov 2024 21:25:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Head=20Node=20UI=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: konva, react-konva 설치 * feat: space 페이지 및 space node 초안 작성 * fix: space 텍스트 중앙 앵커 처리 개선 * test: space node에 대한 storybook 작성 * fix: space node 내 텍스트가 항상 중앙 정렬되도록 개선 - 텍스트 변화 시 중앙정렬이 다시 되지 않는 문제 수정 * test: storybook에서 konva node를 감싸는 Stage 크기 반응형으로 개선 --- packages/frontend/package.json | 2 + packages/frontend/src/App.css | 49 +++--------- packages/frontend/src/App.tsx | 2 + .../components/space/SpaceNode.stories.tsx | 50 +++++++++++++ .../src/components/space/SpaceNode.tsx | 51 +++++++++++++ .../src/components/space/SpaceView.tsx | 49 ++++++++++++ packages/frontend/src/pages/Space.tsx | 28 +++++++ pnpm-lock.yaml | 74 +++++++++++++++++-- 8 files changed, 259 insertions(+), 46 deletions(-) create mode 100644 packages/frontend/src/components/space/SpaceNode.stories.tsx create mode 100644 packages/frontend/src/components/space/SpaceNode.tsx create mode 100644 packages/frontend/src/components/space/SpaceView.tsx create mode 100644 packages/frontend/src/pages/Space.tsx diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 4b4bb6ac..38d1c66f 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -18,9 +18,11 @@ "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "konva": "^9.3.16", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-konva": "^18.2.10", "react-router-dom": "^6.28.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index b9d355df..784be599 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -1,42 +1,13 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +html, +body { + width: 100%; + height: 100%; + padding: 0; + margin: 0; } -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; +#root { + width: 100%; + min-height: 100%; + height: 100%; } diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 5e2247b5..5ffd263b 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,12 +1,14 @@ import "./App.css"; import { Routes, Route, BrowserRouter } from "react-router-dom"; import Home from "./pages/Home.tsx"; +import SpacePage from "./pages/Space.tsx"; function App() { return ( } /> + } /> ); diff --git a/packages/frontend/src/components/space/SpaceNode.stories.tsx b/packages/frontend/src/components/space/SpaceNode.stories.tsx new file mode 100644 index 00000000..1f563ad2 --- /dev/null +++ b/packages/frontend/src/components/space/SpaceNode.stories.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { Layer, Stage } from "react-konva"; + +import type { Meta, StoryObj } from "@storybook/react"; + +import SpaceNode from "./SpaceNode.tsx"; + +export default { + component: SpaceNode, + tags: ["autodocs"], + decorators: [ + (Story, { canvasElement }) => { + // TODO: Konva Node를 위한 decorator 별도로 분리 必 + const [size, setSize] = useState(() => ({ + width: Math.max(canvasElement.clientWidth, 256), + height: Math.max(canvasElement.clientHeight, 256), + })); + + const { width, height } = size; + + useEffect(() => { + const observer = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const { width, height } = entry.contentRect; + setSize({ width, height }); + }); + }); + + observer.observe(canvasElement); + return () => observer.unobserve(canvasElement); + }, [canvasElement]); + + return ( + + + + + + ); + }, + ], +} satisfies Meta; + +export const Normal: StoryObj = { + args: { + label: "HelloWorld", + x: 0, + y: 0, + }, +}; diff --git a/packages/frontend/src/components/space/SpaceNode.tsx b/packages/frontend/src/components/space/SpaceNode.tsx new file mode 100644 index 00000000..168dfb10 --- /dev/null +++ b/packages/frontend/src/components/space/SpaceNode.tsx @@ -0,0 +1,51 @@ +import { forwardRef, useEffect, useRef, useState } from "react"; +import { Circle, Group, Text } from "react-konva"; + +import Konva from "konva"; + +// FIXME: 이런 동작이 많이 필요할 것 같아 별도의 파일로 분리할 것 +function TextWithCenteredAnchor(props: Konva.TextConfig) { + const ref = useRef(null); + + const [offsetX, setOffsetX] = useState(undefined); + const [offsetY, setOffsetY] = useState(undefined); + + useEffect(() => { + if (!ref.current || props.offset !== undefined) { + return; + } + + if (props.offsetX === undefined) { + setOffsetX(ref.current.width() / 2); + } + + if (props.offsetY === undefined) { + setOffsetY(ref.current.height() / 2); + } + }, [props]); + + return ; +} + +export interface SpaceNodeProps { + label?: string; + x: number; + y: number; +} +const SpaceNode = forwardRef( + ({ label, x, y }, ref) => { + // TODO: 색상에 대해 정하기, 크기에 대해 정하기 + const fillColor = "royalblue"; + const textColor = "white"; + + return ( + + + + + ); + }, +); +SpaceNode.displayName = "SpaceNode"; + +export default SpaceNode; diff --git a/packages/frontend/src/components/space/SpaceView.tsx b/packages/frontend/src/components/space/SpaceView.tsx new file mode 100644 index 00000000..101fba4b --- /dev/null +++ b/packages/frontend/src/components/space/SpaceView.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from "react"; +import { Layer, Stage } from "react-konva"; + +import SpaceNode from "./SpaceNode.tsx"; + +interface SpaceViewProps { + autofitTo?: Element | React.RefObject; +} + +export default function SpaceView({ autofitTo }: SpaceViewProps) { + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!autofitTo) { + return undefined; + } + + const containerRef = + "current" in autofitTo ? autofitTo : { current: autofitTo }; + + function resizeStage() { + const container = containerRef.current; + + if (!container) { + return; + } + + const width = container.clientWidth; + const height = container.clientHeight; + + setStageSize({ width, height }); + } + + resizeStage(); + + window.addEventListener("resize", resizeStage); + return () => { + window.removeEventListener("resize", resizeStage); + }; + }, [autofitTo]); + + return ( + + + + + + ); +} diff --git a/packages/frontend/src/pages/Space.tsx b/packages/frontend/src/pages/Space.tsx new file mode 100644 index 00000000..6daf89b0 --- /dev/null +++ b/packages/frontend/src/pages/Space.tsx @@ -0,0 +1,28 @@ +import { useRef } from "react"; +import { useParams } from "react-router-dom"; + +import SpaceView from "@/components/space/SpaceView.tsx"; + +interface SpacePageParams extends Record { + entrySpaceId?: string; +} + +export default function SpacePage() { + const { entrySpaceId } = useParams(); + + if (!entrySpaceId) { + throw new Error(""); + } + + const containerRef = useRef(null); + + return ( +
+ +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5708aad..f35697f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,13 +25,13 @@ importers: version: 9.14.0(jiti@1.21.6) eslint-config-airbnb-base: specifier: ^15.0.0 - version: 15.0.0(eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) + version: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@9.14.0(jiti@1.21.6)) eslint-plugin-import: specifier: ^2.31.0 - version: 2.31.0(eslint@9.14.0(jiti@1.21.6)) + version: 2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)) eslint-plugin-prettier: specifier: ^5.2.1 version: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6))(prettier@3.3.3) @@ -70,6 +70,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + konva: + specifier: ^9.3.16 + version: 9.3.16 lucide-react: specifier: ^0.454.0 version: 0.454.0(react@18.3.1) @@ -79,6 +82,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-konva: + specifier: ^18.2.10 + version: 18.2.10(konva@9.3.16)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^6.28.0 version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1211,6 +1217,9 @@ packages: '@types/react-dom@18.3.1': resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} + '@types/react-reconciler@0.28.8': + resolution: {integrity: sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==} + '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} @@ -2291,6 +2300,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + its-fine@1.2.5: + resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==} + peerDependencies: + react: '>=18.0' + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2356,6 +2370,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + konva@9.3.16: + resolution: {integrity: sha512-qa47cefGDDHzkToGRGDsy24f/Njrz7EHP56jQ8mlDcjAPO7vkfTDeoBDIfmF7PZtpfzDdooafQmEUJMDU2F7FQ==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2839,6 +2856,19 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-konva@18.2.10: + resolution: {integrity: sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==} + peerDependencies: + konva: ^8.0.1 || ^7.2.5 || ^9.0.0 + react: '>=18.0.0' + react-dom: '>=18.0.0' + + react-reconciler@0.29.2: + resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.3.1 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -4570,6 +4600,10 @@ snapshots: dependencies: '@types/react': 18.3.12 + '@types/react-reconciler@0.28.8': + dependencies: + '@types/react': 18.3.12 + '@types/react@18.3.12': dependencies: '@types/prop-types': 15.7.13 @@ -5247,11 +5281,11 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)): dependencies: confusing-browser-globals: 1.0.11 eslint: 9.14.0(jiti@1.21.6) - eslint-plugin-import: 2.31.0(eslint@9.14.0(jiti@1.21.6)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)) object.assign: 4.1.5 object.entries: 1.1.8 semver: 6.3.1 @@ -5268,16 +5302,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@1.21.6)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3) eslint: 9.14.0(jiti@1.21.6) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@1.21.6)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -5288,7 +5323,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.14.0(jiti@1.21.6) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@1.21.6)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@1.21.6)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -5299,6 +5334,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.13.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -5763,6 +5800,11 @@ snapshots: isexe@2.0.0: {} + its-fine@1.2.5(react@18.3.1): + dependencies: + '@types/react-reconciler': 0.28.8 + react: 18.3.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -5813,6 +5855,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + konva@9.3.16: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6211,6 +6255,22 @@ snapshots: react-is@17.0.2: {} + react-konva@18.2.10(konva@9.3.16)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@types/react-reconciler': 0.28.8 + its-fine: 1.2.5(react@18.3.1) + konva: 9.3.16 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-reconciler: 0.29.2(react@18.3.1) + scheduler: 0.23.2 + + react-reconciler@0.29.2(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):