Skip to content

Commit 17a7115

Browse files
authored
Merge pull request #17 from WebCreatorX/feat/canvas-rendering
스타일 아키텍처 리팩토링 및 레이아웃 노드(Stack, Group) 구현
2 parents 4856e91 + bca1638 commit 17a7115

File tree

23 files changed

+357
-517
lines changed

23 files changed

+357
-517
lines changed

apps/editor/db.json

Lines changed: 55 additions & 108 deletions
Large diffs are not rendered by default.

apps/editor/src/components/editor/Canvas.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import {
44
useCanvas,
5+
useClearNode,
56
useCurNodes,
67
useSelectedNodeId,
78
useSelectNode,
@@ -26,10 +27,17 @@ export default function Canvas() {
2627
const updateNode = useUpdateNodeLayout();
2728
const canvasState = useCanvas();
2829
const setCanvas = useSetCanvas();
30+
const clearNode = useClearNode();
2931

3032
const isPanning = useRef(false);
3133
const lastMousePos = useRef({ x: 0, y: 0 });
3234

35+
function getParentNode(parentId: string | null): WcxNode | undefined {
36+
if (!parentId) return undefined;
37+
38+
return nodes?.find((n) => n.id === parentId);
39+
}
40+
3341
//FIXME-각 노드들에 key속성 추가해주기. -> 리액트 경고 발생
3442
//FIXME-nodes가 비어있는 상황에서 에러발생. -> Base Condition에 Root가 들어간다.(Root는 단지 더미 노드일뿐 로직에 들어가면 안된다.)
3543
/**
@@ -52,6 +60,7 @@ export default function Canvas() {
5260
return (
5361
<EditorNodeWrapper
5462
node={parentNode}
63+
parentNode={getParentNode(parentNode.parent_id)}
5564
selectedId={selectedNodeId}
5665
updateNode={updateNode}
5766
selectNode={selectNode}
@@ -77,6 +86,7 @@ export default function Canvas() {
7786
return (
7887
<EditorNodeWrapper
7988
node={parentNode}
89+
parentNode={getParentNode(parentNode.parent_id)}
8090
selectedId={selectedNodeId}
8191
updateNode={updateNode}
8292
selectNode={selectNode}
@@ -89,10 +99,11 @@ export default function Canvas() {
8999

90100
return (
91101
<div
102+
data-component-type="canvas"
92103
className="relative h-full w-full flex-1 cursor-grab overflow-hidden bg-white active:cursor-grabbing"
93104
onWheel={(e) => handleWheel({ canvas: canvasState, e, setCanvas })}
94105
onMouseDown={(e) =>
95-
handleMouseDown({ e, isPanning, lastMousePos, selectNode })
106+
handleMouseDown({ e, isPanning, lastMousePos, clearNode })
96107
}
97108
onMouseMove={(e) =>
98109
handleMouseMove({ e, isPanning, lastMousePos, setCanvas, canvasState })
Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,22 @@
1-
21
import { WcxNode } from "@repo/ui/types/nodes";
32
import { NodeStyle } from "@repo/ui/types/styles";
43

54
// ComponentDefault Data Type
65
// 추천 코드를 반영하여 layout 필드를 분리하고 타입 안정성을 강화했습니다.
76
export interface ComponentDefaults {
87
props: Record<string, unknown>; // 각 노드 타입에 맞는 props (통합 노드 타입에서 추론)
9-
style: NodeStyle; // @repo/ui의 규격화된 스타일 구조 (root 등)
8+
style: NodeStyle; // @repo/ui의 규격화된 스타일 구조 (평탄화된 구조)
109
layout: WcxNode['layout']; // x, y, width, height, zIndex
1110
}
1211

1312
export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
14-
Hero: {
15-
props: {
16-
heading: "Hero Heading",
17-
subHeading: "Hero SubHeading",
18-
image: {
19-
url: "https://via.placeholder.com/800x400",
20-
},
21-
button: {
22-
text: "Action",
23-
link: "#",
24-
},
25-
},
26-
style: {
27-
root: {
28-
layout: {
29-
display: "flex",
30-
flexDirection: "column",
31-
alignItems: "center",
32-
justifyContent: "center",
33-
},
34-
background: { backgroundColor: "#f0f0f0" },
35-
},
36-
},
37-
layout: {
38-
x: 0,
39-
y: 0,
40-
width: 1000, // WcxNode layout 타입에 맞춰 숫자로 지정
41-
height: 400,
42-
zIndex: 0,
43-
},
44-
},
4513
Image: {
4614
props: {
4715
src: "https://via.placeholder.com/400x300",
4816
alt: "Image",
4917
caption: "Image Caption",
5018
},
51-
style: {
52-
root: {},
53-
},
19+
style: {},
5420
layout: {
5521
x: 0,
5622
y: 0,
@@ -65,13 +31,9 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
6531
level: "h2",
6632
},
6733
style: {
68-
root: {
69-
typography: {
70-
color: "#000000",
71-
fontSize: "24px",
72-
fontWeight: "bold",
73-
},
74-
},
34+
color: "#000000",
35+
fontSize: "24px",
36+
fontWeight: "bold",
7537
},
7638
layout: {
7739
x: 0,
@@ -87,12 +49,8 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
8749
level: "h5",
8850
},
8951
style: {
90-
root: {
91-
typography: {
92-
color: "#333333",
93-
fontSize: "16px",
94-
},
95-
},
52+
color: "#333333",
53+
fontSize: "16px",
9654
},
9755
layout: {
9856
x: 0,
@@ -107,11 +65,12 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
10765
text: "Button",
10866
},
10967
style: {
110-
root: {
111-
background: { backgroundColor: "#007bff" },
112-
typography: { color: "#ffffff" },
113-
effects: { borderRadius: "4px" },
114-
},
68+
backgroundColor: "#007bff",
69+
color: "#ffffff",
70+
borderRadius: "4px",
71+
display: "flex",
72+
alignItems: "center",
73+
justifyContent: "center",
11574
},
11675
layout: {
11776
x: 0,
@@ -126,9 +85,8 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
12685
tagName: "div",
12786
},
12887
style: {
129-
root: {
130-
effects: { border: "1px dashed #ccc" },
131-
},
88+
border: "1px dashed #ccc",
89+
backgroundColor: "#ffffff",
13290
},
13391
layout: {
13492
x: 0,
@@ -146,10 +104,9 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
146104
closeOnOverlayClick: true,
147105
},
148106
style: {
149-
root: {
150-
background: { backgroundColor: "#ffffff" },
151-
effects: { borderRadius: "8px" },
152-
},
107+
backgroundColor: "#ffffff",
108+
borderRadius: "8px",
109+
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
153110
},
154111
layout: {
155112
x: 0,
@@ -159,4 +116,36 @@ export const COMPONENT_DEFAULTS: Record<WcxNode['type'], ComponentDefaults> = {
159116
zIndex: 100,
160117
},
161118
},
119+
// Stack 추가
120+
Stack: {
121+
props: {},
122+
style: {
123+
display: "flex",
124+
flexDirection: "column",
125+
gap: "10px",
126+
padding: "20px",
127+
backgroundColor: "#f9fafb",
128+
border: "1px solid #e5e7eb",
129+
},
130+
layout: {
131+
x: 0,
132+
y: 0,
133+
width: 300,
134+
height: 300,
135+
zIndex: 0,
136+
},
137+
},
138+
// Group 추가
139+
Group: {
140+
props: {},
141+
style: {},
142+
layout: {
143+
x: 0,
144+
y: 0,
145+
width: 200,
146+
height: 200,
147+
zIndex: 0,
148+
},
149+
},
162150
};
151+

apps/editor/src/stores/useEditorStore.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const useEditorStore = create(
99
immer(
1010
combine(
1111
{
12-
selectedNodeId: null as string | null,
1312
nodes: null as null | WcxNode[], //TODO- 추후에 현재 페이지에 해당하는 노드들을 받아오는 로직을 통해 해당 상태가 업데이트 되야 한다. -> EditorStoreInitializer컴포넌트에서 담당
1413
canvas: { dx: 0, dy: 0, scale: 1 },
14+
selectedDepthPath: [] as string[],
1515
},
1616
(set, get) => ({
1717
setNode(nodes: WcxNode[]) {
@@ -57,19 +57,41 @@ const useEditorStore = create(
5757
return res;
5858
//TODO- 재귀 삭제 함수로 추출된 노드id는 deleteNodes에 담겨 있다. 이 데이터를 바탕으로 DB수정 시도
5959
},
60-
selectNode(id: string | null) {
61-
set(
62-
(state) => {
63-
state.selectedNodeId = id;
64-
},
65-
false,
66-
"editorStore/selectNode",
67-
);
60+
61+
//FIXME-🐛 버그 발견! -> 하위 노드에서 다른 가지로 넘어갈때 다시 상위 노드가 선택되는 버그 발견. 같은 계층의 자식 노드로 가지를 옮기려면 바로 선택될 수 있어야한다.
62+
selectNode(targetNodeId: string) {
63+
const path = get().selectedDepthPath;
64+
const nodes = get().nodes;
65+
if (!nodes) return;
66+
67+
while (true) {
68+
const targetNode = nodes.find((node) => node.id === targetNodeId);
69+
if (!targetNode) return;
70+
const parentNodeId = targetNode.parent_id;
71+
72+
if (parentNodeId === null) {
73+
set((state) => {
74+
state.selectedDepthPath = [targetNodeId];
75+
});
76+
break;
77+
}
78+
79+
const parentPos = path.indexOf(parentNodeId);
80+
81+
if (parentPos !== -1) {
82+
set((state) => {
83+
state.selectedDepthPath.splice(parentPos + 1);
84+
state.selectedDepthPath.push(targetNodeId);
85+
});
86+
break;
87+
}
88+
targetNodeId = parentNodeId;
89+
}
6890
},
6991
clearNode() {
7092
set(
7193
(state) => {
72-
state.selectedNodeId = null;
94+
state.selectedDepthPath = [];
7395
},
7496
false,
7597
"editorStore/clearNode",
@@ -125,6 +147,30 @@ const useEditorStore = create(
125147
state.canvas = { ...state.canvas, ...updates };
126148
});
127149
},
150+
151+
//TODO-'Node참조값 전달' vs nodeId 전달후 스코프 안에서 파싱 고민해보기
152+
addItemToStack: (nodeId: string, stackId: string) =>
153+
set((state) => {
154+
if (!state.nodes) return state;
155+
const node = state.nodes.find((n) => n.id === nodeId);
156+
const stack = state.nodes.find((n) => n.id === stackId);
157+
if (!node || !stack || stack.type !== "Stack") {
158+
return state;
159+
}
160+
161+
// Stack의 현재 items
162+
//Stack노드의 하위 자식들을 'position'Props에 따라 오름차순 정렬
163+
const currentItems = state.nodes
164+
.filter((n) => n.parent_id === stackId)
165+
.sort((a, b) => a.position - b.position);
166+
167+
//오름차순 정렬후 마지막 idx 배정
168+
const insertIndex = currentItems.length;
169+
// insertIndex 이후의 items position 업데이트
170+
node.position = insertIndex;
171+
node.parent_id = stackId;
172+
node.style.position = "relative";
173+
}),
128174
}),
129175
),
130176
),
@@ -165,7 +211,10 @@ export const useDeleteNode = () => useEditorStore((store) => store.deleteNode);
165211
* 선택된 노드가 없으면 null을 반환합니다.
166212
*/
167213
export const useSelectedNodeId = () =>
168-
useEditorStore((store) => store.selectedNodeId);
214+
useEditorStore((store) => {
215+
const path = store.selectedDepthPath;
216+
return path.length > 0 ? path[path.length - 1] : null;
217+
});
169218

170219
/**
171220
* [Action] 특정 노드를 선택(포커스)하는 함수를 반환합니다.

apps/editor/src/utils/editor/canvasMouseHandler.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface handleMouseDown {
99
e: React.MouseEvent;
1010
isPanning: IsPanning;
1111
lastMousePos: LastMousePos;
12-
selectNode: (id: string | null) => void;
12+
clearNode: () => void;
1313
}
1414

1515
interface handleMouseMove {
@@ -28,10 +28,17 @@ export function handleMouseDown({
2828
e,
2929
isPanning,
3030
lastMousePos,
31-
selectNode,
31+
clearNode,
3232
}: handleMouseDown) {
3333
if (e.button === 0) {
34-
selectNode(null);
34+
const target = e.target as HTMLElement;
35+
const componentElement = target.closest<HTMLElement>(
36+
"[data-component-type]",
37+
);
38+
if (!componentElement) return;
39+
if (componentElement.dataset.componentType !== "canvas") return;
40+
clearNode();
41+
3542
return;
3643
}
3744
e.preventDefault();

packages/ui/src/components/Button.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@ import processNodeStyles from "utils/processNodeStyles";
55

66
export default function ButtonComponent({
77
node,
8-
props,
9-
style,
108
children,
119
}: NodeComponentProps<ButtonNode>) {
1210
const { mode } = useBuilderMode();
13-
const { text, action } = props;
11+
const { text, action } = node.props;
1412

15-
//스타일 변환(className은 제외, 오직 CSS속성만)
16-
const nodeStyleObj = processNodeStyles(style);
13+
const cssProps = processNodeStyles(node.style);
1714

1815
//액션 함수 생성
1916
const excuteAction = useActionHandler(action);
@@ -33,12 +30,13 @@ export default function ButtonComponent({
3330
return (
3431
<button
3532
type="button"
33+
data-component-type={node.type}
3634
data-component-id={node.id}
37-
style={nodeStyleObj.root}
38-
className={`${style.root?.className} ${mode === "editor" ? "cursor-default" : "cursor-pointer"} flex h-full w-full items-center justify-center transition-all active:scale-95`}
39-
onClick={clickHandler} //이벤트 연결
35+
style={cssProps}
36+
className={`${node.style.className || ""} ${mode === "editor" ? "cursor-default" : "cursor-pointer"} flex h-full w-full items-center justify-center transition-all active:scale-95`}
37+
onClick={clickHandler}
4038
>
41-
<span style={nodeStyleObj.text}>{text}</span>
39+
{text}
4240
{children}
4341
</button>
4442
);

0 commit comments

Comments
 (0)