-
Notifications
You must be signed in to change notification settings - Fork 3
[FE] 마인드맵 통합 엔진 구현 및 컴포넌트 적용 #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 48 commits
f3219db
88802f9
c3aa8a4
fcbe51f
d6f31e8
48cb2be
5b2c16c
c4ae057
f314eca
27dc17e
5a4e28e
22c14ca
8fad6f4
e50f86d
47b1c89
b23ea3f
49aaaa6
c115ed6
5b10e8c
f16746f
00ac99a
108f966
0375b1d
3fa5dd5
ec0ae52
2362db6
18ce880
ccbcd8d
87125d1
c848e0e
800fe6c
2012914
54c0e4c
7dc6978
a19ab80
7a00394
d95475a
69ad8cd
b199d09
d020a6d
0ea5579
be10380
566f598
e6a528d
101b32e
a0458c8
0be0203
24b8543
7b4498b
89718e9
62eeb93
c5d0a17
70bc1e5
b9cf82a
8306b29
a7320a7
21df283
0371501
0efd6b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| export const MOUSE_DOWN = { | ||
| left: 0, | ||
| wheel: 1, | ||
| right: 2, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { useMindMapDragSession } from "@/features/mindmap/hooks/useMindmapContext"; | ||
| /** | ||
| * 드래그 세션(start/end)에서만 style 텍스트가 바뀌도록 분리 | ||
| * - 원본(static-graph)만 흐리게 처리 | ||
| * - moving-fragment는 영향 받지 않음 | ||
| */ | ||
| export default function DragGhostStyle() { | ||
| const { isDragging, dragSubtreeIds } = useMindMapDragSession(); | ||
|
|
||
| if (!isDragging || !dragSubtreeIds || dragSubtreeIds.size === 0) return null; | ||
|
|
||
| const escapeAttr = (v: string) => v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); | ||
|
|
||
| const rules: string[] = []; | ||
| dragSubtreeIds.forEach((id) => { | ||
| const safe = escapeAttr(String(id)); | ||
| rules.push(`.static-graph [data-node-id="${safe}"] { opacity: 0.2; }`); | ||
| rules.push(`.static-graph path[data-edge-child="${safe}"] { opacity: 0.2; }`); | ||
arty0928 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
|
|
||
| return <style>{rules.join("\n")}</style>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,127 @@ | ||||||||||
| import { edgeVariants } from "@/features/mindmap/components/EdgeLayer"; | ||||||||||
| import TempNode, { TEMP_NODE_SIZE } from "@/features/mindmap/node/components/temp_node/TempNode"; | ||||||||||
| import { NodeDirection, NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||||||||||
| import { getContentBounds } from "@/features/mindmap/utils/node_geometry"; | ||||||||||
| import { getBezierPath } from "@/features/mindmap/utils/path"; | ||||||||||
|
|
||||||||||
| type DropIndicatorProps = { | ||||||||||
| targetId: NodeId; | ||||||||||
| direction: NodeDirection; | ||||||||||
| nodeMap: Map<NodeId, NodeElement>; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const GHOST_GAP_X = 100; | ||||||||||
| const SIBLING_GAP_Y = 16; | ||||||||||
|
|
||||||||||
| export default function DropIndicator({ targetId, direction, nodeMap }: DropIndicatorProps) { | ||||||||||
| const targetNode = nodeMap.get(targetId); | ||||||||||
| if (!targetNode || !direction) return null; | ||||||||||
|
|
||||||||||
| const targetWidth = targetNode.width || 200; | ||||||||||
| const targetHeight = targetNode.height || 60; | ||||||||||
|
||||||||||
| const targetWidth = targetNode.width || 200; | |
| const targetHeight = targetNode.height || 60; | |
| const targetWidth = targetNode.width || DEFAULT_NODE_WIDTH; | |
| const targetHeight = targetNode.height || DEFAULT_NODE_HEIGHT; |
References
- Hardcoding values like
200and60as fallback dimensions in a component reduces flexibility and maintainability. These should be defined as constants or passed as props.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { cva, VariantProps } from "class-variance-authority"; | ||
|
|
||
| import { NodeColor } from "@/features/mindmap/node/constants/colors"; | ||
| import { NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||
| import { getParentChildEdgeAnchors } from "@/features/mindmap/utils/node_geometry"; | ||
| import { getBezierPath } from "@/features/mindmap/utils/path"; | ||
| import { cn } from "@/utils/cn"; | ||
|
|
||
| type EdgeLayerProps = { | ||
| nodeMap: Map<NodeId, NodeElement>; | ||
| filterNode: NodeElement[]; //이번 레이어에서 그릴 노드 ID(선명하게, 투명하게 그려야 하는 경우가 나뉨) | ||
| color?: NodeColor; | ||
| type?: "active" | "ghost"; | ||
| } & VariantProps<typeof edgeVariants>; | ||
|
|
||
| export const edgeVariants = cva("fill-none", { | ||
| variants: { | ||
| type: { | ||
| active: "stroke", | ||
| ghost: "stroke-2 stroke-node-blue-op-100 [stroke-dasharray:8_4]", | ||
| }, | ||
| color: { | ||
| violet: "stroke-node-violet-op-100", | ||
| blue: "stroke-node-blue-op-100", | ||
| skyblue: "stroke-node-skyblue-op-100", | ||
| mint: "stroke-node-mint-op-100", | ||
| cyan: "stroke-node-cyan-op-100", | ||
| purple: "stroke-node-purple-op-100", | ||
| magenta: "stroke-node-magenta-op-100", | ||
| navy: "stroke-node-navy-op-100", | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| export default function EdgeLayer({ nodeMap, color, type = "active", filterNode }: EdgeLayerProps) { | ||
| return ( | ||
| <g className="edge-layer"> | ||
| {filterNode.map((node) => { | ||
| if (!node.parentId || node.parentId === "empty") return null; | ||
|
|
||
| const parent = nodeMap.get(node.parentId); | ||
| if (!parent) return null; | ||
|
|
||
| const { start, end } = getParentChildEdgeAnchors(parent, node); | ||
|
|
||
| const pathD = getBezierPath(start.x, start.y, end.x, end.y); | ||
|
|
||
| return ( | ||
| <g key={`edge-${node.id}`}> | ||
| <path | ||
| d={pathD} | ||
| data-edge-to={node.id} | ||
| data-edge-from={parent.id} | ||
| className={cn(edgeVariants({ type, color }))} | ||
| /> | ||
| </g> | ||
| ); | ||
| })} | ||
| </g> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import DropIndicator from "@/features/mindmap/components/DropIndicator"; | ||
| import MovingNodeFragment from "@/features/mindmap/components/MovingNodeFragment"; | ||
| import { InteractionSnapshot } from "@/features/mindmap/types/interaction"; | ||
| import { NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||
|
|
||
| type InteractionLayerProps = { | ||
| nodeMap: Map<NodeId, NodeElement>; | ||
| status: InteractionSnapshot; | ||
| }; | ||
| export default function InteractionLayer({ nodeMap, status }: InteractionLayerProps) { | ||
| const { mode, draggingNodeId, dragDelta, dragSubtreeIds, baseNode } = status; | ||
|
|
||
| if (mode !== "dragging" || !draggingNodeId || !dragSubtreeIds) return null; | ||
|
|
||
| return ( | ||
| <g className="interaction-layer"> | ||
| {baseNode.targetId && baseNode.direction && ( | ||
| <DropIndicator targetId={baseNode.targetId} direction={baseNode.direction} nodeMap={nodeMap} /> | ||
| )} | ||
|
|
||
| <MovingNodeFragment filterIds={dragSubtreeIds} nodeMap={nodeMap} delta={dragDelta} /> | ||
| </g> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { useEffect } from "react"; | ||
|
|
||
| import DragGhostStyle from "@/features/mindmap/components/DragGhostStyle"; | ||
| import InteractionLayer from "@/features/mindmap/components/InteractionLayer"; | ||
| import StaticLayer from "@/features/mindmap/components/StaticLayer"; | ||
| import { | ||
| useMindMapCore, | ||
| useMindMapInteractionFrame, | ||
| useMindMapVersion, | ||
| } from "@/features/mindmap/hooks/useMindmapContext"; | ||
| import { useViewportEvents } from "@/features/mindmap/hooks/useViewportEvents"; | ||
| import { NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||
|
|
||
| /** | ||
| * interaction 프레임은 이 컴포넌트만 구독해서, movingFragment만 리렌더되도록 분리 | ||
| */ | ||
| function InteractionOverlay({ nodeMap }: { nodeMap: Map<NodeId, NodeElement> }) { | ||
| const status = useMindMapInteractionFrame(); | ||
|
|
||
| // mode가 바뀔 때만 DOM의 속성을 변경 (리렌더링 유발 X) | ||
| useEffect(() => { | ||
| const root = document.querySelector(".mindmap-render-root") as HTMLElement; | ||
|
||
| if (root) { | ||
| root.setAttribute("data-dragging", status.mode === "dragging" ? "true" : "false"); | ||
| } | ||
| }, [status.mode]); | ||
|
|
||
| return <InteractionLayer status={status} nodeMap={nodeMap} />; | ||
| } | ||
|
|
||
| function MindMapInnerRenderer() { | ||
| const mindmap = useMindMapCore(); | ||
| const version = useMindMapVersion(); | ||
|
|
||
| if (!mindmap) return null; | ||
| useViewportEvents(); | ||
|
|
||
| const nodeMap = mindmap.tree.nodes; | ||
|
|
||
| return ( | ||
| <g className="mindmap-render-root" data-version={version}> | ||
| <StaticLayer nodeMap={nodeMap} /> | ||
| <DragGhostStyle /> | ||
| <InteractionOverlay nodeMap={nodeMap} /> | ||
| </g> | ||
| ); | ||
| } | ||
|
|
||
| export default function MindMapRenderer() { | ||
| const mindmap = useMindMapCore(); | ||
|
|
||
| if (!mindmap || !mindmap.getIsReady()) return null; | ||
| return <MindMapInnerRenderer />; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { useMemo } from "react"; | ||
|
|
||
| import EdgeLayer from "@/features/mindmap/components/EdgeLayer"; | ||
| import NodeItem from "@/features/mindmap/node/components/node/NodeItem"; | ||
| import { NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||
|
|
||
| /** | ||
| * nodeMap: 전체 맵 | ||
| * filterIds : 마우스에 붙어 움직일 노드 Ids | ||
| * delta : 이동량 | ||
| */ | ||
| type MovingNodeFragmentProps = { | ||
| nodeMap: Map<NodeId, NodeElement>; | ||
| filterIds: Set<NodeId>; // | ||
| delta: { x: number; y: number }; | ||
| }; | ||
|
|
||
| /** 마우스로 잡고 노드를 움직일때 마우스에 따라다니는 덩어리 노드들 */ | ||
| export default function MovingNodeFragment({ filterIds, nodeMap, delta }: MovingNodeFragmentProps) { | ||
| // 전체 nodeMap 중 movingHead ~ 자식 노드를 하나의 덩어리화, 맵 내용이 변경될때만 fragment 다시 그림 | ||
| const { fragmentNodes, fragmentMap } = useMemo(() => { | ||
| const map = new Map<NodeId, NodeElement>(); | ||
|
|
||
| // 드래그 중인 노드들만 맵에 담기 | ||
| filterIds.forEach((id) => { | ||
| const node = nodeMap.get(id); | ||
| if (node) { | ||
| map.set(id, node); | ||
| } | ||
| }); | ||
|
|
||
| // 엣지가 그릴 대상을 '부모가 같은 드래그 그룹 안에 있는 경우'로 제한 | ||
| const nodesForEdge = Array.from(map.values()).filter((node) => map.has(node.parentId)); | ||
|
|
||
| return { fragmentNodes: nodesForEdge, fragmentMap: map }; | ||
| }, [filterIds, nodeMap]); | ||
|
|
||
| return ( | ||
| <g transform={`translate(${delta.x}, ${delta.y})`} className="moving-fragment-group"> | ||
| <EdgeLayer nodeMap={fragmentMap} type="active" filterNode={fragmentNodes} color="violet" /> | ||
|
|
||
| {Array.from(filterIds).map((id) => ( | ||
| <NodeItem key={`moving-${id}`} nodeId={id} measure={false} /> | ||
| ))} | ||
| </g> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import EdgeLayer from "@/features/mindmap/components/EdgeLayer"; | ||
| import NodeItem from "@/features/mindmap/node/components/node/NodeItem"; | ||
| import { NodeElement, NodeId } from "@/features/mindmap/types/node"; | ||
|
|
||
| type StaticLayerProps = { | ||
| nodeMap: Map<NodeId, NodeElement>; | ||
| }; | ||
|
|
||
| export default function StaticLayer({ nodeMap }: StaticLayerProps) { | ||
| const allNodes = Array.from(nodeMap.values()); | ||
|
|
||
| return ( | ||
| <g className="static-graph"> | ||
| <EdgeLayer nodeMap={nodeMap} filterNode={allNodes} color="violet" /> | ||
| {allNodes.map((node) => ( | ||
| <NodeItem key={node.id} nodeId={node.id} /> | ||
| ))} | ||
| </g> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EdgeLayer컴포넌트에서 엣지path에data-edge-to속성을 사용하고 있습니다. 하지만 여기서는data-edge-child선택자를 사용하여 스타일을 적용하고 있어, 드래그 중인 노드에 연결된 엣지가 흐려지지 않는 버그가 발생합니다. 선택자를data-edge-to로 수정해야 합니다.