Skip to content

Commit 8ccdc16

Browse files
authored
Merge pull request #21 from WebCreatorX/feat/canvas-rendering
🔖 feat & fix: Portal 오버레이 패턴 적용 및 Stack 아이템 드래그 점프 버그 수정
2 parents 262fced + 6ca0a9b commit 8ccdc16

File tree

6 files changed

+368
-27
lines changed

6 files changed

+368
-27
lines changed
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# 트러블슈팅: Stack Item 리사이즈 후 드래그 시 노드 점프 버그
2+
3+
> **발생일**: 2026-02-25
4+
> **파일**: `packages/ui/src/core/EditorNodeWrapper.tsx`
5+
> **라이브러리**: `react-rnd@10.5.2`
6+
7+
---
8+
9+
## 1. 버그 증상
10+
11+
Stack 내부의 `position: relative` 아이템 노드를 **리사이즈한 후 드래그를 시작하면**, 노드가 엉뚱한 위치로 순간이동(점프)하는 현상이 발생했다.
12+
13+
- 리사이즈 중에는 정상적으로 flexbox 정렬을 따름
14+
- 드래그를 시작하는 순간만 점프 발생
15+
- 리사이즈 없이 바로 드래그하면 정상 동작
16+
17+
---
18+
19+
## 2. 원인 분석
20+
21+
### 2-1. react-rnd의 위치 제어 방식
22+
23+
`react-rnd`는 노드의 위치를 **CSS `transform: translate(x, y)`** 로 제어한다.
24+
`position` prop에 `{ x, y }` 값을 전달하면, 내부적으로 해당 값을 `transform`에 반영한다.
25+
26+
### 2-2. Stack Item의 특수 처리 (`!transform-none`)
27+
28+
Stack 내부 아이템(`position: relative`)은 Flexbox 정렬을 유지해야 하므로,
29+
`isTransformActive``false`일 때 `!transform-none` CSS 클래스를 강제 적용해
30+
react-rnd의 transform을 무력화하고 있었다.
31+
32+
```tsx
33+
className={clsx(
34+
"group cursor-pointer",
35+
hasRelativePosition && !isTransformActive && "!transform-none",
36+
)}
37+
```
38+
39+
드래그 시작(`onDragStart`) 시에는 `setIsTransformActive(true)`로 이 클래스가 제거되고,
40+
react-rnd의 transform이 다시 활성화된다.
41+
42+
### 2-3. 버그의 핵심: 리사이즈 중 내부 position 누적
43+
44+
**`!transform-none`이 적용되어 DOM에서는 transform이 보이지 않더라도,
45+
react-rnd 라이브러리는 리사이즈 중에 내부 position 상태를 계속 누적 변경하고 있다.**
46+
47+
특히 **top 방향 / left 방향 핸들**로 리사이즈할 경우, 요소가 시각적으로 이동해야 하므로
48+
react-rnd가 내부적으로 `translate(x, y)` 값을 계산해 내부 ref에 저장한다.
49+
50+
```
51+
리사이즈 전: 내부 position = { x: 0, y: 0 }
52+
↓ top-left 방향으로 리사이즈
53+
리사이즈 후: 내부 position = { x: -50, y: -30 } ← 누적됨!
54+
(DOM에서는 !transform-none으로 가려져 있어서 보이지 않음)
55+
```
56+
57+
### 2-4. 드래그 시작 시 점프 발생 흐름
58+
59+
```
60+
1. onDragStart 실행
61+
2. setIsTransformActive(true) → !transform-none 클래스 제거
62+
3. react-rnd가 리사이즈 중 누적된 내부 position { x: -50, y: -30 }을
63+
transform에 그대로 반영 → 노드 점프! 💥
64+
4. d.node.offsetLeft / offsetTop 읽음
65+
→ 이미 튀어버린 위치 기준으로 좌표를 읽음 → 잘못된 좌표로 이동
66+
```
67+
68+
---
69+
70+
## 3. 시도한 해결책과 왜 실패했는가
71+
72+
### ❌ 시도 1: `onResizeStop`에서 position 초기화
73+
74+
```tsx
75+
// onResizeStop 내부
76+
if (hasRelativePosition) {
77+
setDragPosition({ x: 0, y: 0 });
78+
rndRef.current?.updatePosition({ x: 0, y: 0 });
79+
}
80+
```
81+
82+
**실패 원인:**
83+
`onResizeStop`에서 초기화해도, 이후 사용자가 리사이즈 핸들을 다시 조작하면
84+
또다시 내부 position이 누적된다.
85+
결정적으로, `onResizeStop` 이후와 `onDragStart` 사이에 사용자 인터랙션이 없어도
86+
react-rnd 내부에서 추가적인 position 변화가 일어날 수 있어 근본적인 해결이 어렵다.
87+
즉, **"리사이즈가 끝날 때" 초기화는 타이밍이 맞지 않는다.**
88+
89+
---
90+
91+
## 4. 최종 해결책
92+
93+
### `onDragStart`에서 항상 먼저 초기화
94+
95+
드래그가 시작되는 바로 그 시점, **transform이 활성화되기 직전**
96+
`rndRef.current?.updatePosition({ x: 0, y: 0 })`을 호출해
97+
react-rnd 내부 position을 강제로 `{x:0, y:0}`으로 초기화한다.
98+
99+
```tsx
100+
onDragStart={(e, d) => {
101+
e.stopPropagation();
102+
setDraggingId(id);
103+
104+
if (hasRelativePosition) {
105+
// ✅ 핵심: isTransformActive를 true로 바꾸기 전에
106+
// react-rnd 내부 position을 반드시 먼저 리셋해야 한다.
107+
// 리사이즈 중 누적된 내부 position이 드래그 활성화와 동시에
108+
// transform에 반영되어 노드가 튀는 버그를 방지.
109+
rndRef.current?.updatePosition({ x: 0, y: 0 });
110+
111+
const { offsetLeft, offsetTop } = d.node;
112+
setIsTransformActive(true);
113+
// ...
114+
}
115+
}}
116+
```
117+
118+
### 왜 이 방법이 동작하는가?
119+
120+
| 단계 | 이전(버그) | 이후(수정) |
121+
|---|---|---|
122+
| 리사이즈 중 | 내부 position 누적 | 내부 position 누적 (동일) |
123+
| `onDragStart` 진입 | 누적된 값 그대로 유지 | `updatePosition(0,0)` 으로 강제 리셋 |
124+
| `setIsTransformActive(true)` | 누적된 position이 transform에 반영 → 점프 | 내부 position이 `{0,0}`이므로 안전하게 transform 활성화 |
125+
| `offsetLeft/offsetTop` 읽기 | 잘못된 위치 기준 | 올바른 flexbox 위치 기준 |
126+
127+
**타이밍이 핵심이다.** `setIsTransformActive(true)`로 transform을 활성화하기 **이전에**
128+
내부 position을 초기화해야 React의 렌더링 사이클에서 올바른 순서가 보장된다.
129+
130+
---
131+
132+
## 5. 핵심 교훈
133+
134+
### "React 상태 초기화"와 "라이브러리 내부 상태 초기화"는 다르다
135+
136+
```
137+
setDragPosition({ x: 0, y: 0 })
138+
→ React가 관리하는 state만 초기화. react-rnd 내부 ref에는 영향 없음.
139+
140+
rndRef.current?.updatePosition({ x: 0, y: 0 })
141+
→ react-rnd가 내부적으로 관리하는 DOM transform 상태까지 직접 초기화.
142+
```
143+
144+
react-rnd처럼 **uncontrolled 방식으로 DOM을 직접 조작하는 라이브러리**
145+
React의 state/props와 별도로 자체적인 내부 상태를 가진다.
146+
Zustand나 useState로 아무리 상태를 바꿔도, **라이브러리 내부 상태는 별도로 초기화**해야 한다.
147+
148+
### 리사이즈 방향에 따라 position이 변한다
149+
150+
react-rnd는 `bottom-right` 방향 리사이즈만 할 때는 position이 변하지 않는다.
151+
그러나 **`top`, `left`, `top-left`, `top-right`, `bottom-left` 방향**으로 리사이즈하면
152+
요소가 반대 방향으로 이동해야 하므로 내부 position을 자동으로 변경한다.
153+
이 동작은 CSS의 `transform-origin`과 다른, react-rnd 고유의 position 계산 방식이다.
154+
155+
### 초기화 타이밍은 "활성화 직전"이어야 한다
156+
157+
- `onResizeStop`: 너무 이름 — 이후 또 다른 조작이 발생할 수 있음
158+
- `onDragStart` (transform 활성화 직전): ✅ 가장 안전한 타이밍
159+
160+
---
161+
162+
## 6. 관련 코드 위치
163+
164+
| 항목 | 위치 |
165+
|---|---|
166+
| 수정 파일 | `packages/ui/src/core/EditorNodeWrapper.tsx` |
167+
| 수정 함수 | `onDragStart` 핸들러 내부 |
168+
| 핵심 API | `rndRef.current?.updatePosition({ x: 0, y: 0 })` |
169+
| react-rnd 타입 정의 | `node_modules/react-rnd/lib/index.d.ts` |
170+
171+
---
172+
173+
## 7. 최종 적용 diff
174+
175+
```diff
176+
onDragStart={(e, d) => {
177+
e.stopPropagation();
178+
setDraggingId(id);
179+
if (hasRelativePosition) {
180+
+ rndRef.current?.updatePosition({ x: 0, y: 0 }); // 내부 position 강제 리셋
181+
const { offsetLeft, offsetTop } = d.node;
182+
setIsTransformActive(true);
183+
- flushSync(() => {
184+
- updateNode(id, { x: offsetLeft, y: offsetTop });
185+
- setDragPosition({ x: offsetLeft, y: offsetTop });
186+
- });
187+
}
188+
}}
189+
```

apps/editor/db.json

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
"justifyContent": "center",
1616
"gap": "20px",
1717
"padding": "50px 24px",
18-
"maxWidth": "1200px",
1918
"backgroundColor": "#FFFFFF",
2019
"className": "content-section"
2120
},
@@ -28,8 +27,8 @@
2827
"type": "Heading",
2928
"position": 0,
3029
"layout": {
31-
"x": 0,
32-
"y": 0,
30+
"x": 1000,
31+
"y": 1000,
3332
"width": 1200,
3433
"height": 100,
3534
"zIndex": 2
@@ -94,7 +93,6 @@
9493
"justifyContent": "center",
9594
"gap": "20px",
9695
"padding": "50px 24px",
97-
"maxWidth": "1200px",
9896
"backgroundColor": "#F9FAFB",
9997
"className": "gallery-section"
10098
},
@@ -133,8 +131,8 @@
133131
},
134132
"style": {
135133
"position": "absolute",
136-
"top": "50px",
137-
"left": "700px",
134+
"top": "0px",
135+
"left": "0px",
138136
"width": "600px",
139137
"height": "300px",
140138
"objectFit": "cover",

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import handleWheel from "@/utils/editor/handleWheel";
2020
import { DragProvider } from "@repo/ui/context/dragContext";
2121
import EditorNodeWrapper from "@repo/ui/core/EditorNodeWrapper";
2222
import NodeRenderer from "@repo/ui/core/NodeRenderer";
23+
import SelectionOverlay from "@repo/ui/core/SelectionOverlay";
2324
import { WcxNode } from "@repo/ui/types/nodes";
2425
import React, { useRef } from "react";
2526

@@ -134,6 +135,11 @@ export default function Canvas() {
134135
<DragProvider value={useDragStore}>
135136
{renderTree({ id: null })}
136137
</DragProvider>
138+
{/* 포탈 기반 선택 오버레이 — 노드 DOM 트리 바깥에서 렌더링 */}
139+
<SelectionOverlay
140+
selectedNodeId={selectedNodeId}
141+
canvas={canvasState}
142+
/>
137143
</div>
138144
</div>
139145
</div>

packages/ui/src/components/Stack.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export default function StackComponent({
1818
style={cssProps}
1919
className={cn("h-full w-full", {
2020
"node.style.className": node.style.className,
21-
"ring-semantic-info ring-2": hoveredStackId === node.id,
2221
})}
2322
>
2423
{children}

packages/ui/src/core/EditorNodeWrapper.tsx

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import clsx from "clsx";
33
import { useDragStore } from "context/dragContext";
44
import { useRef, useState } from "react";
5+
import { flushSync } from "react-dom";
56
import { Rnd } from "react-rnd";
67
import { WcxNode } from "types";
78
import { CanvasState, Layer } from "types/rnd";
@@ -40,6 +41,7 @@ export default function EditorNodeWrapper({
4041
cursor: "move",
4142
};
4243

44+
const rndRef = useRef<Rnd>(null);
4345
const [isTransformActive, setIsTransformActive] = useState(false);
4446
const [dragPosition, setDragPosition] = useState<{
4547
x: number;
@@ -52,8 +54,9 @@ export default function EditorNodeWrapper({
5254
const { id } = node;
5355
const { width, height, x, y, zIndex } = node.layout;
5456
const selectedNodeGuideClasses = {
55-
handle: "bg-white border-2 rounded-full border-rnd-handle !w-2 !h-2 ",
56-
outline: "ring ring-2 ring-rnd-handle",
57+
// 시각적 핸들은 SelectionOverlay 포탈에서 렌더링.
58+
// Rnd의 핸들은 인터랙션만 담당 (투명하게 유지)
59+
handle: "opacity-0 !w-3 !h-3 ",
5760
};
5861

5962
// 필요한 데이터만 구독
@@ -95,31 +98,42 @@ export default function EditorNodeWrapper({
9598

9699
return (
97100
<Rnd
98-
101+
ref={rndRef}
99102
className={clsx(
100103
"group cursor-pointer",
101104
hasRelativePosition && !isTransformActive && "!transform-none", // relative인 경우에는 stack의 정렬을 지키기 위해 transform을 꺼놓는다.
102105
)}
103106
size={{ width, height }}
104-
position={{ x, y }}
107+
position={hasRelativePosition ? dragPosition : { x, y }}
105108
style={{
106109
...wrapperStyle,
107-
position: hasRelativePosition ? "relative" : (node.style.position as any),
110+
position:
111+
hasRelativePosition && !isTransformActive
112+
? "relative"
113+
: (node.style.position as any) || "absolute",
114+
115+
top: hasRelativePosition && isTransformActive ? 0 : undefined,
116+
left: hasRelativePosition && isTransformActive ? 0 : undefined,
108117
zIndex,
109118
}}
110119
scale={canvas.scale}
120+
//FIXME-transform이 풀리는 순간 상태변화의 타이밍 순서 문제 때문에 버그 발생하는듯?
111121
onDragStart={(e, d) => {
112122
e.stopPropagation();
113123
setDraggingId(id); //드래그 시작 알림
124+
console.log(d.node.offsetLeft);
114125
if (hasRelativePosition) {
115126
const { offsetLeft, offsetTop } = d.node;
116-
updateNode(id, { x: offsetLeft, y: offsetTop });
117-
setDragPosition({ x: offsetLeft, y: offsetTop });
127+
rndRef.current?.updatePosition({ x: 0, y: 0 });
128+
118129
setIsTransformActive(true);
119130
console.log(
120-
`좌표 보정 작동 offsetLeft - ${offsetLeft} // offsetTop - ${offsetTop} `,
131+
`[dragStart]_현재 추출된 노드 좌표 offsetLeft - ${offsetLeft} // offsetTop - ${offsetTop} `,
121132
);
122133
}
134+
console.log(
135+
`[dragStart]현재 노드의 실제 렌더링position - x:${x}, y:${y}`,
136+
);
123137
}}
124138
//TODO-이동중에 로직 실행하면 성능상 부담이 될 수 있다... 최적화 고민 해보기
125139
onDrag={(e, d) => {
@@ -131,18 +145,15 @@ export default function EditorNodeWrapper({
131145
}}
132146
onDragStop={(e, d) => {
133147
setIsTransformActive(false);
134-
console.log(`현재 노드 ${id}- 포지션 ${node.style.position}`);
148+
console.log(`[dragStop]현재 노드 ${id}- 포지션 ${node.style.position}`);
135149
const stackId = findStackId(e);
136150

137151
setDraggingId(null);
138152
setHoveredStackId(null);
139153

140-
console.log(
141-
`드래그 종료시 노드의 좌표 x:${d.node.offsetLeft}, y:${d.node.offsetTop}`,
142-
);
154+
console.log(`[dragStop]노드의 좌표 x:${x}, y:${y}`);
143155

144156
if (hasRelativePosition) {
145-
console.log("relative position");
146157
return;
147158
}
148159

@@ -168,13 +179,21 @@ export default function EditorNodeWrapper({
168179
})
169180
}
170181
*/
171-
onResizeStop={(e, dir, ref, delta, pos) =>
182+
onResizeStop={(e, dir, ref, delta, pos) => {
172183
updateNode(id, {
173184
width: parseInt(ref.style.width),
174185
height: parseInt(ref.style.height),
175186
...(hasRelativePosition ? {} : pos),
176-
})
177-
}
187+
});
188+
// [버그 수정] react-rnd는 리사이즈 중 top/left 방향 핸들 사용 시
189+
// 내부 position을 누적 변경한다. !transform-none으로 DOM에선 보이지 않지만
190+
// 드래그 시작 시 isTransformActive가 true로 바뀌면서 누적된 값이 한꺼번에 반영되어
191+
// 노드가 튀는 버그가 발생한다. → 리사이즈 종료 시 내부 position을 {x:0, y:0}으로 강제 동기화.
192+
// if (hasRelativePosition) {
193+
// setDragPosition({ x: 0, y: 0 });
194+
// rndRef.current?.updatePosition({ x: 0, y: 0 });
195+
// }
196+
}}
178197
enableResizing={isGroup ? undefined : isSelected ? undefined : false}
179198
disableDragging={!isSelected}
180199
resizeHandleClasses={{
@@ -198,10 +217,7 @@ export default function EditorNodeWrapper({
198217
selectNode(id);
199218
}}
200219
style={wrapperStyle}
201-
className={clsx(
202-
"relative h-full w-full transition-shadow duration-200",
203-
isSelected && selectedNodeGuideClasses.outline,
204-
)}
220+
className="relative h-full w-full"
205221
>
206222
{children}
207223
</div>

0 commit comments

Comments
 (0)