diff --git a/apps/editor/db.json b/apps/editor/db.json index 307b5c4..efb6540 100644 --- a/apps/editor/db.json +++ b/apps/editor/db.json @@ -4,12 +4,16 @@ "id": "1002", "page_id": 201, "parent_id": null, - "type": "Container", + "type": "Stack", "position": 1, "layout": { "x": 0, "y": 0, "width": 1440, "height": 800, "zIndex": 1 }, "props": {}, "style": { - "display": "block", + "display": "flex", + "flexDirection": "column", + "alignItems": "center", + "justifyContent": "center", + "gap": "20px", "padding": "50px 24px", "maxWidth": "1200px", "backgroundColor": "#FFFFFF", @@ -24,8 +28,8 @@ "type": "Heading", "position": 0, "layout": { - "x": 100, - "y": 50, + "x": 0, + "y": 0, "width": 1200, "height": 100, "zIndex": 2 @@ -35,6 +39,7 @@ "level": "h2" }, "style": { + "position": "relative", "color": "#111827", "fontSize": "36px", "textAlign": "center", @@ -49,8 +54,8 @@ "type": "Text", "position": 1, "layout": { - "x": 100, - "y": 150, + "x": 0, + "y": 0, "width": 800, "height": 200, "zIndex": 2 @@ -60,6 +65,7 @@ "level": "p" }, "style": { + "position": "relative", "color": "#374151", "fontSize": "18px", "lineHeight": 1.6, @@ -71,7 +77,7 @@ "id": "1005", "page_id": 201, "parent_id": null, - "type": "Container", + "type": "Stack", "position": 2, "layout": { "x": 0, @@ -82,8 +88,10 @@ }, "props": { "id": "gallery" }, "style": { - "display": "grid", - "gridTemplateColumns": "1fr 1fr", + "display": "flex", + "flexDirection": "row", + "flexWrap": "wrap", + "justifyContent": "center", "gap": "20px", "padding": "50px 24px", "maxWidth": "1200px", @@ -98,12 +106,13 @@ "parent_id": "1005", "type": "Image", "position": 0, - "layout": { "x": 50, "y": 50, "width": 600, "height": 300, "zIndex": 2 }, + "layout": { "x": 0, "y": 0, "width": 600, "height": 300, "zIndex": 2 }, "props": { "src": "data:image/jpeg;base64,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", "alt": "갤러리 이미지 1" }, "style": { + "position": "relative", "width": "100%", "height": "300px", "objectFit": "cover", @@ -123,10 +132,14 @@ "alt": "갤러리 이미지 2" }, "style": { - "width": "100%", + "position": "absolute", + "top": "50px", + "left": "700px", + "width": "600px", "height": "300px", "objectFit": "cover", - "borderRadius": "8px" + "borderRadius": "8px", + "zIndex": 10 }, "created_at": "2025-11-13T06:25:00Z" }, @@ -136,12 +149,13 @@ "parent_id": "1005", "type": "Text", "position": 2, - "layout": { "x": 50, "y": 360, "width": 600, "height": 50, "zIndex": 2 }, + "layout": { "x": 0, "y": 0, "width": 600, "height": 50, "zIndex": 2 }, "props": { "text": "이미지 설명 1", "level": "p" }, "style": { + "position": "relative", "textAlign": "center", "fontSize": "14px", "color": "#6B7280", @@ -155,12 +169,13 @@ "parent_id": "1005", "type": "Text", "position": 3, - "layout": { "x": 700, "y": 360, "width": 600, "height": 50, "zIndex": 2 }, + "layout": { "x": 0, "y": 0, "width": 600, "height": 50, "zIndex": 2 }, "props": { "text": "이미지 설명 2", "level": "p" }, "style": { + "position": "relative", "textAlign": "center", "fontSize": "14px", "color": "#6B7280", diff --git a/apps/editor/src/components/editor/Canvas.tsx b/apps/editor/src/components/editor/Canvas.tsx index 75604b1..ee8fac2 100644 --- a/apps/editor/src/components/editor/Canvas.tsx +++ b/apps/editor/src/components/editor/Canvas.tsx @@ -1,6 +1,8 @@ "use client"; +import { useDragStore } from "@/stores/useDragStore"; import { + useAddItemToStack, useCanvas, useClearNode, useCurNodes, @@ -15,6 +17,7 @@ import { handleMouseUp, } from "@/utils/editor/canvasMouseHandler"; import handleWheel from "@/utils/editor/handleWheel"; +import { DragProvider } from "@repo/ui/context/dragContext"; import EditorNodeWrapper from "@repo/ui/core/EditorNodeWrapper"; import NodeRenderer from "@repo/ui/core/NodeRenderer"; import { WcxNode } from "@repo/ui/types/nodes"; @@ -28,7 +31,9 @@ export default function Canvas() { const canvasState = useCanvas(); const setCanvas = useSetCanvas(); const clearNode = useClearNode(); + const addItemToStack = useAddItemToStack(); + //TODO-이거 뭐임? const isPanning = useRef(false); const lastMousePos = useRef({ x: 0, y: 0 }); @@ -65,6 +70,7 @@ export default function Canvas() { updateNode={updateNode} selectNode={selectNode} canvas={canvasState} + addItemToStack={addItemToStack} > @@ -91,6 +97,7 @@ export default function Canvas() { updateNode={updateNode} selectNode={selectNode} canvas={canvasState} + addItemToStack={addItemToStack} > {children} @@ -123,7 +130,10 @@ export default function Canvas() { {/* 배경 격자 (Helper Grid) */}
- {renderTree({ id: null })} + {/* 실제 스토어 인스턴스를 주입 */} + + {renderTree({ id: null })} +
diff --git a/apps/editor/src/stores/useDragStore.ts b/apps/editor/src/stores/useDragStore.ts new file mode 100644 index 0000000..c60004b --- /dev/null +++ b/apps/editor/src/stores/useDragStore.ts @@ -0,0 +1,30 @@ +import { createStore } from "zustand"; +import { combine, devtools } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +//context에서 스토어를 주입하기 위해 스토어 객체로 생성 +export const useDragStore = createStore( + devtools( + immer( + combine( + { + draggingNodeId: null as null | string, + hoveredStackId: null as null | string, + }, + (set) => ({ + setDraggingId: (id: string | null) => + set((store) => { + store.draggingNodeId = id; + }), + setHoveredStackId: (id: string | null) => + set((store) => { + store.hoveredStackId = id; + }), + }), + ), + ), + { + name: "dragStore", + }, + ), +); diff --git a/apps/editor/src/stores/useEditorStore.ts b/apps/editor/src/stores/useEditorStore.ts index f034a32..fac6140 100644 --- a/apps/editor/src/stores/useEditorStore.ts +++ b/apps/editor/src/stores/useEditorStore.ts @@ -69,6 +69,7 @@ const useEditorStore = create( if (!targetNode) return; const parentNodeId = targetNode.parent_id; + //선택하려는 노드가 최상위 노드일 경우(부모가 ROOT) if (parentNodeId === null) { set((state) => { state.selectedDepthPath = [targetNodeId]; @@ -129,7 +130,7 @@ const useEditorStore = create( // 1. 최상위 속성 업데이트 (type 등) // (주의: 객체 타입인 style, props, layout을 통째로 덮어쓰지 않도록 별도 처리) // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { style, props, layout: _layout, ...rest } = updates; + const { style, props, layout, ...rest } = updates; Object.assign(targetNode, rest); // 2. 하위 객체 병합 업데이트 @@ -151,27 +152,31 @@ const useEditorStore = create( //TODO-'Node참조값 전달' vs nodeId 전달후 스코프 안에서 파싱 고민해보기 addItemToStack: (nodeId: string, stackId: string) => - set((state) => { - if (!state.nodes) return state; - const node = state.nodes.find((n) => n.id === nodeId); - const stack = state.nodes.find((n) => n.id === stackId); - if (!node || !stack || stack.type !== "Stack") { - return state; - } - - // Stack의 현재 items - //Stack노드의 하위 자식들을 'position'Props에 따라 오름차순 정렬 - const currentItems = state.nodes - .filter((n) => n.parent_id === stackId) - .sort((a, b) => a.position - b.position); - - //오름차순 정렬후 마지막 idx 배정 - const insertIndex = currentItems.length; - // insertIndex 이후의 items position 업데이트 - node.position = insertIndex; - node.parent_id = stackId; - node.style.position = "relative"; - }), + set( + (state) => { + if (!state.nodes) return state; + const node = state.nodes.find((n) => n.id === nodeId); + const stack = state.nodes.find((n) => n.id === stackId); + if (!node || !stack || stack.type !== "Stack") { + return state; + } + + // Stack의 현재 items + //Stack노드의 하위 자식들을 'position'Props에 따라 오름차순 정렬 + const currentItems = state.nodes + .filter((n) => n.parent_id === stackId) + .sort((a, b) => a.position - b.position); + + //오름차순 정렬후 마지막 idx 배정 + const insertIndex = currentItems.length; + // insertIndex 이후의 items position 업데이트 + node.position = insertIndex; + node.parent_id = stackId; + node.style.position = "relative"; + }, + false, + "editStore/addItemToStack", + ), }), ), ), @@ -263,3 +268,19 @@ export const useSetCanvas = () => useEditorStore((store) => store.setCanvas); */ export const useGetDescendantIds = () => useEditorStore((store) => store.getDescendantIds); + +export const useAddItemToStack = () => + useEditorStore((store) => store.addItemToStack); + +/** + * [Selector] ID를 기준으로 특정 노드 객체를 반환합니다. + * 해당 ID의 노드가 업데이트되면 이를 사용하는 컴포넌트만 리렌더링됩니다. + * @param nodeId - 찾고자 하는 노드의 ID + */ +export const useGetNodeById = (nodeId: string) => { + return useEditorStore((store) => + store.nodes?.find(({ id }) => id === nodeId), + ); +}; + +//노드 순서 바꾸는 훅 고민하기, 트리에서도 노드의 순서 바꿀 수 있도록 고려하기. diff --git a/packages/ui/package.json b/packages/ui/package.json index b5320e0..ffb13ca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.26", - "react-rnd": "^10.5.2" + "react-rnd": "^10.5.2", + "zustand": "^5.0.8" } } diff --git a/packages/ui/src/components/Stack.tsx b/packages/ui/src/components/Stack.tsx index b1e0f15..d38c418 100644 --- a/packages/ui/src/components/Stack.tsx +++ b/packages/ui/src/components/Stack.tsx @@ -1,3 +1,5 @@ +import { cn } from "@repo/utils"; +import { useDragStore } from "context/dragContext"; import { NodeComponentProps, StackNode } from "types"; import processNodeStyles from "utils/processNodeStyles"; @@ -5,6 +7,8 @@ export default function StackComponent({ node, children, }: NodeComponentProps) { + const hoveredStackId = useDragStore((s) => s.hoveredStackId); + const cssProps = processNodeStyles(node.style); return ( @@ -12,7 +16,10 @@ export default function StackComponent({ data-component-type={node.type} data-component-id={node.id} style={cssProps} - className={`${node.style.className || ""} h-full w-full`} + className={cn("h-full w-full", { + "node.style.className": node.style.className, + "ring-semantic-info ring-2": hoveredStackId === node.id, + })} > {children} diff --git a/packages/ui/src/context/dragContext.tsx b/packages/ui/src/context/dragContext.tsx new file mode 100644 index 0000000..14e27d2 --- /dev/null +++ b/packages/ui/src/context/dragContext.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import { StoreApi, useStore } from "zustand"; + +export interface DragState { + draggingNodeId: string | null; + hoveredStackId: string | null; + setDraggingId: (id: string | null) => void; + setHoveredStackId: (id: string | null) => void; +} + +//Context는 스토어 객체를 운반_스토어의 값(state)가 아니라 스토어 객체(StoreApi)를 담는 그릇 생성. +const DragStoreContext = createContext | null>(null); + +export const DragProvider = DragStoreContext.Provider; + +//구독하려는 하위 컴포넌트에서 구독할 수 있는 훅을 사용할 수 있도록 해야한다. +export function useDragStore(selector: (state: DragState) => T): T { + //스토어 객체를 먼저 가져온다. + const store = useContext(DragStoreContext); + if (!store) throw new Error("useDragStore must be used within DragProvider"); + //가져온 스토어 객체를 사용해 하위 컴포넌트의 구독 시스템을 활성화 + return useStore(store, selector); +} diff --git a/packages/ui/src/core/EditorNodeWrapper.tsx b/packages/ui/src/core/EditorNodeWrapper.tsx index e197804..f256e78 100644 --- a/packages/ui/src/core/EditorNodeWrapper.tsx +++ b/packages/ui/src/core/EditorNodeWrapper.tsx @@ -1,5 +1,7 @@ //에디터 모드전용 노드 렌더러 래퍼 컴포넌트 import clsx from "clsx"; +import { useDragStore } from "context/dragContext"; +import { useRef, useState } from "react"; import { Rnd } from "react-rnd"; import { WcxNode } from "types"; import { CanvasState, Layer } from "types/rnd"; @@ -12,6 +14,7 @@ interface WrapperProps { updateNode: (id: string, updates: Partial) => void; //노드의 레이아웃 업데이트 함수 from editor의 스토어 액션 selectNode: (id: string) => void; canvas: CanvasState; + addItemToStack: (draggedId: string, stackId: string) => void; } //에디터 전용 노드 렌더러 래퍼 @@ -24,10 +27,10 @@ export default function EditorNodeWrapper({ updateNode, selectNode, canvas, + addItemToStack, }: WrapperProps) { const isStackItem = parentNode?.type === "Stack"; - const hasRelativePosition = - !node.style.position || node.style.position === "relative"; + const hasRelativePosition = node.style.position === "relative"; const isSwitchItems = isStackItem && hasRelativePosition; @@ -37,6 +40,15 @@ export default function EditorNodeWrapper({ cursor: "move", }; + const [isTransformActive, setIsTransformActive] = useState(false); + const [dragPosition, setDragPosition] = useState<{ + x: number; + y: number; + }>({ + x: 0, + y: 0, + }); + const { id } = node; const { width, height, x, y, zIndex } = node.layout; const selectedNodeGuideClasses = { @@ -44,27 +56,106 @@ export default function EditorNodeWrapper({ outline: "ring ring-2 ring-rnd-handle", }; + // 필요한 데이터만 구독 + const draggingId = useDragStore((s) => s.draggingNodeId); + const hoveredStackId = useDragStore((s) => s.hoveredStackId); + const setDraggingId = useDragStore((s) => s.setDraggingId); + const setHoveredStackId = useDragStore((s) => s.setHoveredStackId); + + //데이터를 바탕으로 가이드 표시 여부 결정 + const isDraggingMyself = draggingId === id; + const isHoveredStack = hoveredStackId === id; + const showGuide = isHoveredStack && draggingId && !isDraggingMyself; + + //클릭된 좌표 기준 stack찾는 함수_재귀를 이용해 최상위의 Stack의 id를 반환합니다. + //TODO-노드 객체만 전달해도 되는거아닌가? -> 일단 노드의 id 반환으로 처리완료.(id vs 객체 반환) + //클릭된 좌표 기준 stack찾는 함수_재귀를 이용해 최상위의 Stack의 id를 반환합니다. + function findStackId(e: any) { + const elements = document.elementsFromPoint(e.clientX, e.clientY); + + for (const element of elements) { + let curStackNode = element.closest( + '[data-component-type="Stack"]', + ) as HTMLElement | null; + + if (!curStackNode) continue; + + // 드래그 중인 노드(=자기 자신)이거나 그 자손인 경우 건너뜁니다 + if (curStackNode.closest(`[data-component-id="${id}"]`)) { + continue; + } + + return curStackNode.getAttribute("data-component-id"); + } + + return null; + } + //TODO- 노드 선택 로직 구현, 선택 ID 공유하는 zustand 스토어 구현 필요 return ( e.stopPropagation()} - //TODO-일단 이동중에 스토어 업데이트는 미루기 -> 성능 이슈 - // onDrag={(e, d) => updateNode(id, { x: d.x, y: d.y })} + onDragStart={(e, d) => { + e.stopPropagation(); + setDraggingId(id); //드래그 시작 알림 + if (hasRelativePosition) { + const { offsetLeft, offsetTop } = d.node; + updateNode(id, { x: offsetLeft, y: offsetTop }); + setDragPosition({ x: offsetLeft, y: offsetTop }); + setIsTransformActive(true); + console.log( + `좌표 보정 작동 offsetLeft - ${offsetLeft} // offsetTop - ${offsetTop} `, + ); + } + }} + //TODO-이동중에 로직 실행하면 성능상 부담이 될 수 있다... 최적화 고민 해보기 + onDrag={(e, d) => { + const stackId = findStackId(e); + + if (stackId !== hoveredStackId) { + setHoveredStackId(stackId); + } + }} onDragStop={(e, d) => { + setIsTransformActive(false); + console.log(`현재 노드 ${id}- 포지션 ${node.style.position}`); + const stackId = findStackId(e); + + setDraggingId(null); + setHoveredStackId(null); + + console.log( + `드래그 종료시 노드의 좌표 x:${d.node.offsetLeft}, y:${d.node.offsetTop}`, + ); + + if (hasRelativePosition) { + console.log("relative position"); + return; + } + + //드래그가 종료될 경우에 외부 아이템이 스택으로 들어오는경우,스택 내부의 아이템이 이동할 경우(포지션 앱솔루트), 내부 아이템끼리 위치 이동할 경우,기본 위치 이동을 생각해야한다. if (isSwitchItems) { //현재 놓인 Y위치에 따라서 노드의 순서 변경을 고려해야한다. //TODO-Stack내부에서 Item 노드의 순서 변경 로직 실행 + } else if (stackId && node.parent_id !== stackId) { + //스택 외부의 노드가 스택 안으로 새롭게 들어오는 경우에만 해당이 된다. + addItemToStack(id, stackId); + } else { + updateNode(id, { x: d.x, y: d.y }); } - updateNode(id, { x: d.x, y: d.y }); }} onResizeStart={(e) => e.stopPropagation()} //TODO-일단 리사이징중에 스토어 업데이트는 미루기 -> 성능 이슈 @@ -81,12 +172,11 @@ export default function EditorNodeWrapper({ updateNode(id, { width: parseInt(ref.style.width), height: parseInt(ref.style.height), - ...pos, + ...(hasRelativePosition ? {} : pos), }) } enableResizing={isGroup ? undefined : isSelected ? undefined : false} disableDragging={!isSelected} - className={clsx("group cursor-pointer", isSelected && "z-50")} resizeHandleClasses={{ bottomLeft: isSelected ? clsx(selectedNodeGuideClasses.handle, "!-left-1 !-bottom-1") @@ -109,11 +199,10 @@ export default function EditorNodeWrapper({ }} style={wrapperStyle} className={clsx( - "h-full w-full transition-shadow duration-200", + "relative h-full w-full transition-shadow duration-200", isSelected && selectedNodeGuideClasses.outline, )} > - {/* 실제 컴포넌트(Hero 등)는 이 안에 렌더링됨 */} {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63efec5..151ed03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: react-rnd: specifier: ^10.5.2 version: 10.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.2.6)(immer@11.1.3)(react@19.1.0) devDependencies: '@tailwindcss/postcss': specifier: ^4