Skip to content

Commit

Permalink
feat: space에서 Subspace 생성 및 Subspace 노드 구현 (#157)
Browse files Browse the repository at this point in the history
* feat: subspace node 추가

* feat: context 변화에 따라 재렌더링하도록 개선

* refactor: autofit 로직 별도 훅으로 분리

* chore: 더 이상 사용되지 않는 mock 데이터 삭제
  • Loading branch information
hoqn authored Nov 28, 2024
1 parent b481473 commit 31d870b
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 80 deletions.
24 changes: 22 additions & 2 deletions packages/frontend/src/components/Node.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Circle, Group, Text } from "react-konva";
import { Circle, Group, KonvaNodeEvents, Text } from "react-konva";
import { useNavigate } from "react-router-dom";

import Konva from "konva";
import { KonvaEventObject } from "konva/lib/Node";
Expand All @@ -10,7 +11,8 @@ type NodeProps = {
y: number;
draggable?: boolean;
children?: ReactNode;
} & Konva.GroupConfig;
} & Konva.GroupConfig &
KonvaNodeEvents;

type NodeHandlers = {
onDragStart: () => void;
Expand Down Expand Up @@ -117,3 +119,21 @@ export function NoteNode({ x, y, name, ...rest }: NoteNodeProps) {
</Node>
);
}

export type SubspaceNodeProps = {
x: number;
y: number;
name: string;
src: string;
} & NodeHandlers;

export function SubspaceNode({ x, y, name, src, ...rest }: SubspaceNodeProps) {
const navigate = useNavigate();

return (
<Node x={x} y={y} onClick={() => navigate(`/space/${src}`)} {...rest}>
<Node.Circle radius={64} fill="#FFF2CB" />
<Node.Text fontSize={16} fontStyle="700" content={name} />
</Node>
);
}
12 changes: 0 additions & 12 deletions packages/frontend/src/components/mock.ts

This file was deleted.

103 changes: 70 additions & 33 deletions packages/frontend/src/components/space/SpaceView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { Layer, Stage } from "react-konva";
import { Html } from "react-konva-utils";

import Konva from "konva";
import type { Node } from "shared/types";

import { createSpace } from "@/api/space";
import Edge from "@/components/Edge";
import { HeadNode, NoteNode } from "@/components/Node";
import { HeadNode, NoteNode, SubspaceNode } from "@/components/Node";
import useAutofit from "@/hooks/useAutofit";
import useDragNode from "@/hooks/useDragNode";
import useYjsSpace from "@/hooks/useYjsSpace";
import { useZoomSpace } from "@/hooks/useZoomSpace.ts";
Expand All @@ -17,15 +19,15 @@ import { MemoizedNearIndicator } from "./NearNodeIndicator";
import PaletteMenu from "./PaletteMenu";

interface SpaceViewProps {
spaceId: string;
autofitTo?: Element | React.RefObject<Element>;
}

const dragBoundFunc = function (this: Konva.Node) {
return this.absolutePosition();
};

export default function SpaceView({ autofitTo }: SpaceViewProps) {
const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) {
const stageRef = React.useRef<Konva.Stage>(null);
const { zoomSpace } = useZoomSpace({ stageRef });

Expand All @@ -35,42 +37,64 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) {

const { drag, dropPosition, handlePaletteSelect } = useDragNode(nodesArray, {
createNode: (type, parentNode, position, name = "New Note") => {
defineNode({ type, x: position.x, y: position.y, name }, parentNode.id);
},
createEdge: (fromNode, toNode) => {
defineEdge(fromNode.id, toNode.id);
},
});
const { startNode, handlers } = drag;

useEffect(() => {
if (!autofitTo) {
return undefined;
}
if (type === "note") {
let src = "";
// FIXME: note 생성 후 id 입력
defineNode(
{
type,
x: position.x,
y: position.y,
name,
src,
},
parentNode.id,
);

const containerRef =
"current" in autofitTo ? autofitTo : { current: autofitTo };
return;
}

function resizeStage() {
const container = containerRef.current;
if (type === "subspace") {
createSpace({
spaceName: name,
userId: "honeyflow",
parentContextNodeId: spaceId,
}).then((res) => {
const [urlPath] = res.urlPath;
defineNode(
{
type,
x: position.x,
y: position.y,
name,
src: urlPath,
},
parentNode.id,
);
});

if (!container) {
return;
}

const width = container.clientWidth;
const height = container.clientHeight;

setStageSize({ width, height });
}
defineNode(
{
type,
x: position.x,
y: position.y,
name,
src: "",
},
parentNode.id,
);
},

resizeStage();
createEdge: (fromNode, toNode) => {
defineEdge(fromNode.id, toNode.id);
},
});
const { startNode, handlers } = drag;

window.addEventListener("resize", resizeStage);
return () => {
window.removeEventListener("resize", resizeStage);
};
}, [autofitTo]);
const stageSize = useAutofit(autofitTo);

const nodeComponents = {
head: (node: Node) => (
Expand All @@ -96,6 +120,19 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) {
dragBoundFunc={dragBoundFunc}
/>
),
subspace: (node: Node) => (
<SubspaceNode
key={node.id}
src={node.src}
x={node.x}
y={node.y}
name={node.name}
onDragStart={() => handlers.onDragStart(node)}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
dragBoundFunc={dragBoundFunc}
/>
),
};

return (
Expand Down Expand Up @@ -145,7 +182,7 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) {
}}
>
<PaletteMenu
items={["note", "image", "url"]}
items={["note", "image", "url", "subspace"]}
onSelect={handlePaletteSelect}
/>
</div>
Expand Down
39 changes: 39 additions & 0 deletions packages/frontend/src/hooks/useAutofit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { RefObject, useEffect, useState } from "react";

export default function useAutofit<T extends Element>(
container: RefObject<T> | T | undefined,
) {
const [size, setSize] = useState({ width: 0, height: 0 });

useEffect(() => {
if (!container) {
return undefined;
}

const containerRef =
"current" in container ? container : { current: container };

function resizeStage() {
const container = containerRef.current;

if (!container) {
return;
}

const width = container.clientWidth;
const height = container.clientHeight;

setSize({ width, height });
}

resizeStage();

// NOTE: ResizeObserver를 도입할 것을 고려할 것
window.addEventListener("resize", resizeStage);
return () => {
window.removeEventListener("resize", resizeStage);
};
}, [container]);

return size;
}
48 changes: 17 additions & 31 deletions packages/frontend/src/hooks/useYjsSpace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,22 @@ const MOCK_DATA = {
};

export default function useYjsSpace() {
const { yDoc, yProvider } = useYjsStore();
const [yContext, setYContext] = useState<Y.Map<unknown>>();
const { yDoc } = useYjsStore();

useEffect(() => {
if (!yDoc) return;
const context = yDoc.getMap("context");
setYContext(context);
}, [yDoc]);

// TODO 코드 개선
const yNodes = yContext?.get("nodes") as Y.Map<Node> | undefined;
const yEdges = yContext?.get("edges") as Y.Map<EdgeWithId> | undefined;
const nodes = useY(yNodes) as SpaceData["nodes"] | undefined;
const edgesRaw = useY(yEdges) as
| Record<string, { from: string; to: string }>
| undefined;
const yContext = yDoc?.getMap("context") as Y.Map<Y.Map<unknown>> | undefined;

const context = useY(yContext) as SpaceData | undefined;
const nodes = context?.nodes;
const edgesRaw = context?.edges as EdgeWithId[] | undefined;

const [edges, setEdges] = useState<SpaceData["edges"] | undefined>();

// update functions

const defineNode = (node: Omit<Node, "id">, parentNodeId?: Node["id"]) => {
const yNodes = yContext?.get("nodes") as Y.Map<Node> | undefined;
const yEdges = yContext?.get("edges") as Y.Map<EdgeWithId> | undefined;

if (!yDoc || !yNodes || !yEdges) {
return;
}
Expand All @@ -61,6 +55,9 @@ export default function useYjsSpace() {
};

const defineEdge = (fromNodeId: string, toNodeId: string) => {
const yNodes = yContext?.get("nodes") as Y.Map<Node> | undefined;
const yEdges = yContext?.get("edges") as Y.Map<EdgeWithId> | undefined;

if (!yDoc || !yNodes || !yEdges) {
return;
}
Expand All @@ -76,6 +73,9 @@ export default function useYjsSpace() {
};

const deleteNode = (nodeId: Node["id"]) => {
const yNodes = yContext?.get("nodes") as Y.Map<Node> | undefined;
const yEdges = yContext?.get("edges") as Y.Map<EdgeWithId> | undefined;

if (!yDoc || !yNodes || !yEdges) {
return;
}
Expand All @@ -96,6 +96,8 @@ export default function useYjsSpace() {
};

const updateNode = (nodeId: Node["id"], patch: Partial<Omit<Node, "id">>) => {
const yNodes = yContext?.get("nodes") as Y.Map<Node> | undefined;

const prev = yNodes?.get(nodeId);

if (!yNodes || !prev) {
Expand Down Expand Up @@ -124,24 +126,8 @@ export default function useYjsSpace() {
}, {});

setEdges(edgesData);

// NOTE nodes는 포함되지 않아도 될 것 같긴 하다 -> 이후 이를 고려해 로직 수정
}, [edgesRaw, nodes]);

useEffect(() => {
if (!yDoc || !yProvider) {
return undefined;
}

const handleOnSync = (/* isSynced: boolean */) => {
const yContext = yDoc.getMap("context");
setYContext(yContext);
};

yProvider.on("sync", handleOnSync);
return () => yProvider.off("sync", handleOnSync);
}, [yDoc, yProvider]);

/* NOTE - 개발 단계에서 프론트엔드 Space 개발을 위한 Mock 데이터 임의 설정 */
if (import.meta.env.VITE_MOCK) {
if (!yDoc || !nodes || Object.keys(nodes || {}).length === 0) {
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/hooks/yjs/useY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export default function useY<T extends Y.AbstractType<any> | undefined>(
};

if (yData) {
yData.observe(callback);
return () => yData.unobserve(callback);
yData.observeDeep(callback);
return () => yData.unobserveDeep(callback);
}

return () => {};
Expand Down
1 change: 1 addition & 0 deletions packages/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type Node = {
x: number;
y: number;
type: "head" | "note" | "url" | "image" | "subspace";
src: string;
};

export type Edge = {
Expand Down

0 comments on commit 31d870b

Please sign in to comment.