Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
875b46f
✨ 스토어 객체 생성
y-minion Feb 10, 2026
901f85c
✨ drag스토어 주입하는 context생성
y-minion Feb 10, 2026
7405580
💬 스토어 주석 추가
y-minion Feb 11, 2026
def5944
🐛 스토어의 immer 버그 수정
y-minion Feb 11, 2026
fda2fdf
✨ 드래그 전용 스토어를 컨텍스트에 주입
y-minion Feb 11, 2026
93a5009
♻️ 기존 목데이터의 노드 타입 수정
y-minion Feb 11, 2026
7026dd7
♻️ Stack노드 가이드라인 렌더링 수정
y-minion Feb 11, 2026
6d4bc8a
♻️ Stack 추적 로직 수정
y-minion Feb 11, 2026
0bf4edd
스토어 디버깅 위한 이름 설정
y-minion Feb 11, 2026
98939f6
♻️ 스택노드 구현 환경에 맞게 데이터의 스타일 속성 수정
y-minion Feb 11, 2026
ceb81f3
🐛 스택 내부의 Postion: absolute인 item 노드의 이동이 제한되던 버그 해결
y-minion Feb 12, 2026
59fbade
🐛 스택 내부의 item노드들이 정렬되지 않던 버그 수정
y-minion Feb 12, 2026
d13f327
🐛 노드들의 드래그 모션 버그 수정
y-minion Feb 13, 2026
3d967c8
docs(editor): add JSDoc to useGetNodeById and remove TODO
y-minion Feb 20, 2026
abaacd3
노드 튀는 버그 찾기 위한 디버깅 준비
y-minion Feb 20, 2026
16fff70
build: update pnpm-lock.yaml to resolve CI error
y-minion Feb 20, 2026
18ee2f6
build(ui): add zustand to dependencies
y-minion Feb 20, 2026
441e1d5
chore(editor): fix ESLint warnings in stores
y-minion Feb 20, 2026
3c64440
Merge branch 'develop' into feat/canvas-rendering
y-minion Feb 20, 2026
81123b6
fix(ui): remove duplicate style prop in EditorNodeWrapper
y-minion Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions apps/editor/db.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -24,8 +28,8 @@
"type": "Heading",
"position": 0,
"layout": {
"x": 100,
"y": 50,
"x": 0,
"y": 0,
"width": 1200,
"height": 100,
"zIndex": 2
Expand All @@ -35,6 +39,7 @@
"level": "h2"
},
"style": {
"position": "relative",
"color": "#111827",
"fontSize": "36px",
"textAlign": "center",
Expand All @@ -49,8 +54,8 @@
"type": "Text",
"position": 1,
"layout": {
"x": 100,
"y": 150,
"x": 0,
"y": 0,
"width": 800,
"height": 200,
"zIndex": 2
Expand All @@ -60,6 +65,7 @@
"level": "p"
},
"style": {
"position": "relative",
"color": "#374151",
"fontSize": "18px",
"lineHeight": 1.6,
Expand All @@ -71,7 +77,7 @@
"id": "1005",
"page_id": 201,
"parent_id": null,
"type": "Container",
"type": "Stack",
"position": 2,
"layout": {
"x": 0,
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
},
Expand All @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion apps/editor/src/components/editor/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useDragStore } from "@/stores/useDragStore";
import {
useAddItemToStack,
useCanvas,
useClearNode,
useCurNodes,
Expand All @@ -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";
Expand All @@ -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 });

Expand Down Expand Up @@ -65,6 +70,7 @@ export default function Canvas() {
updateNode={updateNode}
selectNode={selectNode}
canvas={canvasState}
addItemToStack={addItemToStack}
>
<NodeRenderer node={parentNode} />
</EditorNodeWrapper>
Expand All @@ -91,6 +97,7 @@ export default function Canvas() {
updateNode={updateNode}
selectNode={selectNode}
canvas={canvasState}
addItemToStack={addItemToStack}
>
<NodeRenderer node={parentNode}>{children}</NodeRenderer>
</EditorNodeWrapper>
Expand Down Expand Up @@ -123,7 +130,10 @@ export default function Canvas() {
{/* 배경 격자 (Helper Grid) */}
<div className="bg-grid-pattern pointer-events-none absolute inset-[-1000%] z-0 h-[3000%] w-[3000%]" />
<div className="relative z-10 h-full w-full">
{renderTree({ id: null })}
{/* 실제 스토어 인스턴스를 주입 */}
<DragProvider value={useDragStore}>
{renderTree({ id: null })}
</DragProvider>
</div>
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions apps/editor/src/stores/useDragStore.ts
Original file line number Diff line number Diff line change
@@ -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",
},
),
);
65 changes: 43 additions & 22 deletions apps/editor/src/stores/useEditorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const useEditorStore = create(
if (!targetNode) return;
const parentNodeId = targetNode.parent_id;

//선택하려는 노드가 최상위 노드일 경우(부모가 ROOT)
if (parentNodeId === null) {
set((state) => {
state.selectedDepthPath = [targetNodeId];
Expand Down Expand Up @@ -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. 하위 객체 병합 업데이트
Expand All @@ -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",
),
}),
),
),
Expand Down Expand Up @@ -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),
);
};

//노드 순서 바꾸는 훅 고민하기, 트리에서도 노드의 순서 바꿀 수 있도록 고려하기.
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 8 additions & 1 deletion packages/ui/src/components/Stack.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { cn } from "@repo/utils";
import { useDragStore } from "context/dragContext";
import { NodeComponentProps, StackNode } from "types";
import processNodeStyles from "utils/processNodeStyles";

export default function StackComponent({
node,
children,
}: NodeComponentProps<StackNode>) {
const hoveredStackId = useDragStore((s) => s.hoveredStackId);

const cssProps = processNodeStyles(node.style);

return (
<div
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}
</div>
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/context/dragContext.tsx
Original file line number Diff line number Diff line change
@@ -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<StoreApi<DragState> | null>(null);

export const DragProvider = DragStoreContext.Provider;

//구독하려는 하위 컴포넌트에서 구독할 수 있는 훅을 사용할 수 있도록 해야한다.
export function useDragStore<T>(selector: (state: DragState) => T): T {
//스토어 객체를 먼저 가져온다.
const store = useContext(DragStoreContext);
if (!store) throw new Error("useDragStore must be used within DragProvider");
//가져온 스토어 객체를 사용해 하위 컴포넌트의 구독 시스템을 활성화
return useStore(store, selector);
}
Loading