Skip to content

Commit

Permalink
feat: Head Node UI 구현 (#85)
Browse files Browse the repository at this point in the history
* chore: konva, react-konva 설치

* feat: space 페이지 및 space node 초안 작성

* fix: space 텍스트 중앙 앵커 처리 개선

* test: space node에 대한 storybook 작성

* fix: space node 내 텍스트가 항상 중앙 정렬되도록 개선

- 텍스트 변화 시 중앙정렬이 다시 되지 않는 문제 수정

* test: storybook에서 konva node를 감싸는 Stage 크기 반응형으로 개선
  • Loading branch information
hoqn authored Nov 11, 2024
1 parent 888bf78 commit abbbe33
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 46 deletions.
2 changes: 2 additions & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 10 additions & 39 deletions packages/frontend/src/App.css
Original file line number Diff line number Diff line change
@@ -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%;
}
2 changes: 2 additions & 0 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/space/:entrySpaceId" element={<SpacePage />} />
</Routes>
</BrowserRouter>
);
Expand Down
50 changes: 50 additions & 0 deletions packages/frontend/src/components/space/SpaceNode.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stage width={width} height={height} draggable>
<Layer offsetX={-width / 2} offsetY={-height / 2}>
<Story />
</Layer>
</Stage>
);
},
],
} satisfies Meta<typeof SpaceNode>;

export const Normal: StoryObj = {
args: {
label: "HelloWorld",
x: 0,
y: 0,
},
};
51 changes: 51 additions & 0 deletions packages/frontend/src/components/space/SpaceNode.tsx
Original file line number Diff line number Diff line change
@@ -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<Konva.Text>(null);

const [offsetX, setOffsetX] = useState<number | undefined>(undefined);
const [offsetY, setOffsetY] = useState<number | undefined>(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 <Text ref={ref} offsetX={offsetX} offsetY={offsetY} {...props} />;
}

export interface SpaceNodeProps {
label?: string;
x: number;
y: number;
}
const SpaceNode = forwardRef<Konva.Group, SpaceNodeProps>(
({ label, x, y }, ref) => {
// TODO: 색상에 대해 정하기, 크기에 대해 정하기
const fillColor = "royalblue";
const textColor = "white";

return (
<Group ref={ref} x={x} y={y}>
<Circle x={0} y={0} radius={48} fill={fillColor} />
<TextWithCenteredAnchor x={0} y={0} text={label} fill={textColor} />
</Group>
);
},
);
SpaceNode.displayName = "SpaceNode";

export default SpaceNode;
49 changes: 49 additions & 0 deletions packages/frontend/src/components/space/SpaceView.tsx
Original file line number Diff line number Diff line change
@@ -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<Element>;
}

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 (
<Stage width={stageSize.width} height={stageSize.height} draggable>
<Layer offsetX={-stageSize.width / 2} offsetY={-stageSize.height / 2}>
<SpaceNode label="HEAD NODE" x={0} y={0} />
</Layer>
</Stage>
);
}
28 changes: 28 additions & 0 deletions packages/frontend/src/pages/Space.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> {
entrySpaceId?: string;
}

export default function SpacePage() {
const { entrySpaceId } = useParams<SpacePageParams>();

if (!entrySpaceId) {
throw new Error("");
}

const containerRef = useRef<HTMLDivElement>(null);

return (
<div
className="text-sm"
style={{ width: "100%", height: "100%" }}
ref={containerRef}
>
<SpaceView autofitTo={containerRef} />
</div>
);
}
Loading

0 comments on commit abbbe33

Please sign in to comment.