Skip to content

Commit c73750f

Browse files
committed
feat: 키 핸들 기반 리사이징 추가
1 parent 92a8c14 commit c73750f

4 files changed

Lines changed: 465 additions & 0 deletions

File tree

src/renderer/components/main/Grid/Grid.jsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ZoomIndicator from "./ZoomIndicator";
1414
import SmartGuidesOverlay from "./SmartGuidesOverlay";
1515
import GridBackground from "./GridBackground";
1616
import MarqueeSelectionOverlay from "./MarqueeSelectionOverlay";
17+
import ResizeHandles from "./ResizeHandles";
1718
import { useGridSelectionStore } from "@stores/useGridSelectionStore";
1819
import { useHistoryStore } from "@stores/useHistoryStore";
1920
import { useUIStore } from "@stores/useUIStore";
@@ -25,6 +26,8 @@ import {
2526
useGridSelection,
2627
useGridContextMenu,
2728
useGridMarquee,
29+
useGridResize,
30+
useSmartGuidesElements,
2831
} from "@hooks/Grid";
2932

3033
export default function Grid({
@@ -136,6 +139,18 @@ export default function Grid({
136139
clientToGridCoords,
137140
});
138141

142+
// 스마트 가이드를 위한 다른 요소들의 bounds 가져오기
143+
const { getOtherElements } = useSmartGuidesElements();
144+
145+
// 리사이즈 훅 사용
146+
const { handleResizeStart, handleResize, handleResizeComplete } =
147+
useGridResize({
148+
selectedElements,
149+
selectedKeyType,
150+
onResizeEnd: syncSelectedElementsToOverlay,
151+
getOtherElements,
152+
});
153+
139154
// 선택된 요소의 z-order 조작 핸들러
140155
const handleSelectedMoveForward = useCallback(async () => {
141156
if (selectedElements.length !== 1) return;
@@ -569,6 +584,37 @@ export default function Grid({
569584
/>
570585
);
571586
})}
587+
{/* 단일 선택 시 리사이즈 핸들 표시 (키 요소만 지원) */}
588+
{selectedElements.length === 1 &&
589+
(() => {
590+
const el = selectedElements[0];
591+
// 키 요소만 리사이즈 지원 (플러그인 요소는 현재 미지원)
592+
if (el.type !== "key" || el.index === undefined) return null;
593+
594+
const pos = positions[selectedKeyType]?.[el.index];
595+
if (!pos) return null;
596+
597+
const bounds = {
598+
x: pos.dx,
599+
y: pos.dy,
600+
width: pos.width || 60,
601+
height: pos.height || 60,
602+
};
603+
604+
return (
605+
<ResizeHandles
606+
bounds={bounds}
607+
zoom={zoom}
608+
panX={panX}
609+
panY={panY}
610+
onResizeStart={handleResizeStart}
611+
onResize={handleResize}
612+
onResizeEnd={handleResizeComplete}
613+
elementId={`key-${el.index}`}
614+
getOtherElements={getOtherElements}
615+
/>
616+
);
617+
})()}
572618
{/* 우클릭 리스트 팝업 */}
573619
<div className="relative">
574620
<ListPopup
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import React, { useCallback, useRef } from "react";
2+
3+
/**
4+
* 8방향 리사이즈 핸들을 표시하는 컴포넌트
5+
* 단일 선택 시에만 표시됨
6+
*/
7+
8+
// ===== 조절 가능한 설정 값들 =====
9+
const HANDLE_VISUAL_SIZE = 8; // 핸들의 시각적 크기 (픽셀)
10+
const HANDLE_HIT_SIZE = 16; // 핸들의 클릭 가능 영역 크기 (픽셀) - 이 값을 조절하여 잡는 범위 변경
11+
const MIN_SIZE = 10; // 키의 최소 크기 (픽셀)
12+
const RESIZE_SNAP_SIZE = 5; // 리사이즈 시 스냅 단위 (픽셀) - 이 값을 조절하여 크기 조절 단위 변경
13+
// ================================
14+
15+
const HANDLE_VISUAL_HALF = HANDLE_VISUAL_SIZE / 2;
16+
const HANDLE_HIT_HALF = HANDLE_HIT_SIZE / 2;
17+
18+
// 8방향 핸들 정의
19+
const HANDLES = [
20+
{ id: "nw", cursor: "nwse-resize", x: 0, y: 0, dx: -1, dy: -1 },
21+
{ id: "n", cursor: "ns-resize", x: 0.5, y: 0, dx: 0, dy: -1 },
22+
{ id: "ne", cursor: "nesw-resize", x: 1, y: 0, dx: 1, dy: -1 },
23+
{ id: "w", cursor: "ew-resize", x: 0, y: 0.5, dx: -1, dy: 0 },
24+
{ id: "e", cursor: "ew-resize", x: 1, y: 0.5, dx: 1, dy: 0 },
25+
{ id: "sw", cursor: "nesw-resize", x: 0, y: 1, dx: -1, dy: 1 },
26+
{ id: "s", cursor: "ns-resize", x: 0.5, y: 1, dx: 0, dy: 1 },
27+
{ id: "se", cursor: "nwse-resize", x: 1, y: 1, dx: 1, dy: 1 },
28+
];
29+
30+
export default function ResizeHandles({
31+
bounds, // { x, y, width, height } - 그리드 좌표
32+
zoom = 1,
33+
panX = 0,
34+
panY = 0,
35+
onResizeStart,
36+
onResize,
37+
onResizeEnd,
38+
elementId, // 스마트 가이드용 요소 ID
39+
getOtherElements, // 스마트 가이드용 다른 요소 가져오기 함수
40+
}) {
41+
const resizeRef = useRef({
42+
isResizing: false,
43+
handleId: null,
44+
startMouseX: 0,
45+
startMouseY: 0,
46+
startBounds: null,
47+
});
48+
49+
const handleMouseDown = useCallback(
50+
(e, handle) => {
51+
e.preventDefault();
52+
e.stopPropagation();
53+
54+
resizeRef.current = {
55+
isResizing: true,
56+
handleId: handle.id,
57+
startMouseX: e.clientX,
58+
startMouseY: e.clientY,
59+
startBounds: { ...bounds },
60+
handle,
61+
};
62+
63+
onResizeStart?.(handle);
64+
65+
const handleMouseMove = (moveEvent) => {
66+
if (!resizeRef.current.isResizing) return;
67+
68+
const { handle, startMouseX, startMouseY, startBounds } =
69+
resizeRef.current;
70+
71+
// 마우스 이동량 계산 (줌 보정)
72+
const rawDeltaX = (moveEvent.clientX - startMouseX) / zoom;
73+
const rawDeltaY = (moveEvent.clientY - startMouseY) / zoom;
74+
75+
// 새 bounds 계산 (스냅 전)
76+
let newX = startBounds.x;
77+
let newY = startBounds.y;
78+
let newWidth = startBounds.width;
79+
let newHeight = startBounds.height;
80+
81+
// 핸들 방향에 따라 크기 조정
82+
if (handle.dx === -1) {
83+
// 왼쪽 핸들: x 이동 + width 조정
84+
newWidth = Math.max(MIN_SIZE, startBounds.width - rawDeltaX);
85+
if (newWidth > MIN_SIZE) {
86+
newX = startBounds.x + rawDeltaX;
87+
} else {
88+
newX = startBounds.x + startBounds.width - MIN_SIZE;
89+
}
90+
} else if (handle.dx === 1) {
91+
// 오른쪽 핸들: width만 조정
92+
newWidth = Math.max(MIN_SIZE, startBounds.width + rawDeltaX);
93+
}
94+
95+
if (handle.dy === -1) {
96+
// 위쪽 핸들: y 이동 + height 조정
97+
newHeight = Math.max(MIN_SIZE, startBounds.height - rawDeltaY);
98+
if (newHeight > MIN_SIZE) {
99+
newY = startBounds.y + rawDeltaY;
100+
} else {
101+
newY = startBounds.y + startBounds.height - MIN_SIZE;
102+
}
103+
} else if (handle.dy === 1) {
104+
// 아래쪽 핸들: height만 조정
105+
newHeight = Math.max(MIN_SIZE, startBounds.height + rawDeltaY);
106+
}
107+
108+
// 그리드 스냅 적용 (RESIZE_SNAP_SIZE 단위로)
109+
newX = Math.round(newX / RESIZE_SNAP_SIZE) * RESIZE_SNAP_SIZE;
110+
newY = Math.round(newY / RESIZE_SNAP_SIZE) * RESIZE_SNAP_SIZE;
111+
newWidth = Math.round(newWidth / RESIZE_SNAP_SIZE) * RESIZE_SNAP_SIZE;
112+
newHeight = Math.round(newHeight / RESIZE_SNAP_SIZE) * RESIZE_SNAP_SIZE;
113+
114+
// 최소 크기 보장
115+
newWidth = Math.max(MIN_SIZE, newWidth);
116+
newHeight = Math.max(MIN_SIZE, newHeight);
117+
118+
onResize?.({
119+
x: newX,
120+
y: newY,
121+
width: newWidth,
122+
height: newHeight,
123+
handle,
124+
});
125+
};
126+
127+
const handleMouseUp = () => {
128+
resizeRef.current.isResizing = false;
129+
document.removeEventListener("mousemove", handleMouseMove);
130+
document.removeEventListener("mouseup", handleMouseUp);
131+
window.removeEventListener("blur", handleMouseUp);
132+
onResizeEnd?.();
133+
};
134+
135+
document.addEventListener("mousemove", handleMouseMove);
136+
document.addEventListener("mouseup", handleMouseUp);
137+
window.addEventListener("blur", handleMouseUp);
138+
},
139+
[bounds, zoom, onResizeStart, onResize, onResizeEnd]
140+
);
141+
142+
if (!bounds) return null;
143+
144+
// 선택 테두리의 중심선 기준 좌표 (테두리 두께 2px의 중심 = 1px)
145+
const borderThickness = 2;
146+
const borderCenter = borderThickness / 2; // 테두리의 중심선까지의 거리
147+
const selectionLeft = bounds.x * zoom + panX - borderCenter;
148+
const selectionTop = bounds.y * zoom + panY - borderCenter;
149+
const selectionWidth = bounds.width * zoom + borderCenter * 2;
150+
const selectionHeight = bounds.height * zoom + borderCenter * 2;
151+
152+
return (
153+
<>
154+
{HANDLES.map((handle) => {
155+
// 핸들 중심 위치 계산 (선택 테두리의 가장자리 중앙에 배치)
156+
const centerX = selectionLeft + selectionWidth * handle.x;
157+
const centerY = selectionTop + selectionHeight * handle.y;
158+
159+
// 시각적 핸들 위치 (중심에서 반만큼 오프셋)
160+
const visualX = centerX - HANDLE_VISUAL_HALF;
161+
const visualY = centerY - HANDLE_VISUAL_HALF;
162+
163+
// 히트 영역 위치 (중심에서 반만큼 오프셋)
164+
const hitX = centerX - HANDLE_HIT_HALF;
165+
const hitY = centerY - HANDLE_HIT_HALF;
166+
167+
return (
168+
<div
169+
key={handle.id}
170+
style={{
171+
position: "absolute",
172+
// 히트 영역 (투명, 더 넓은 클릭 범위)
173+
left: hitX,
174+
top: hitY,
175+
width: HANDLE_HIT_SIZE,
176+
height: HANDLE_HIT_SIZE,
177+
cursor: handle.cursor,
178+
zIndex: 21,
179+
// 히트 영역은 투명
180+
backgroundColor: "transparent",
181+
// 디버깅용: 히트 영역 시각화 (필요시 주석 해제)
182+
// backgroundColor: "rgba(255, 0, 0, 0.2)",
183+
display: "flex",
184+
alignItems: "center",
185+
justifyContent: "center",
186+
}}
187+
onMouseDown={(e) => handleMouseDown(e, handle)}
188+
>
189+
{/* 시각적 핸들 (히트 영역 중앙에 배치) */}
190+
<div
191+
style={{
192+
width: HANDLE_VISUAL_SIZE,
193+
height: HANDLE_VISUAL_SIZE,
194+
backgroundColor: "white",
195+
border: "1px solid rgba(59, 130, 246, 0.8)",
196+
borderRadius: "1px",
197+
// boxShadow: "0 0 2px rgba(0, 0, 0, 0.3)",
198+
pointerEvents: "none",
199+
}}
200+
/>
201+
</div>
202+
);
203+
})}
204+
</>
205+
);
206+
}

src/renderer/hooks/Grid/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ export { useGridZoomPan } from "./useGridZoomPan";
2020
// Hooks - Draggable & Smart Guides
2121
export { useDraggable } from "./useDraggable";
2222
export { useSmartGuidesElements } from "./useSmartGuidesElements";
23+
24+
// Hooks - Resize
25+
export { useGridResize } from "./useGridResize";

0 commit comments

Comments
 (0)