diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 9a15312..0900b9e 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -1,5 +1,34 @@ # 메인플랜: 랜딩 페이지 빌더 +### 가이드라인 고도화 진행(2025-11-16) +- 목표: 객체-객체 스냅 가이드(8px 임계) + - 수직 중심 정렬 가이드/스냅 + - 수평 중심 정렬 가이드/스냅 + - 좌/우/상/하 가장자리 정렬 가이드/스냅 +- TDD + - `components/wysiwyg/Canvas.guidelines.advanced.test.tsx` + - 수직 중심 스냅 가이드(8px) 검증. Moveable 모킹, `act`/`waitFor` 적용 + - `components/wysiwyg/Canvas.guidelines.horizontal.test.tsx` + - 수평 중심 스냅 가이드(8px) 검증 + - `components/wysiwyg/Canvas.guidelines.edges.test.tsx` + - 좌/우 에지 스냅 가이드(8px) 1차 케이스 검증(T→L 등 에지 페어) +- 구현 + - `components/wysiwyg/Canvas.tsx` + - onDrag: 그리드(8px) → 객체 중심 스냅(수직/수평) → 객체 에지 스냅(좌/우/상/하) 우선순위 적용 + - 가이드 좌표: 축별로 에지 > 중심 > 그리드 순서로 표시 + - 드래그 시작 시 가이드 표시, 종료 시 가이드 해제 유지 +- 결과: 전체 테스트 그린(131/131) + +#### 오류 원인/디버깅(메모) +- 실패: 수직 중심 스냅 테스트에서 선택 대상이 기본 선택되지 않아 좌표 미변경 → 레이어 objects 순서를 조정(o1을 첫 요소로) +- 실패: 비동기 상태 반영 전 단정 → Moveable 이벤트 호출을 `act`로 감싸고 결과 검증을 `waitFor`/`findByTestId`로 보강하여 해결 +- 실패: 가장자리 스냅 미구현으로 208px(그리드) 유지 → 에지 스냅(LL/LR/RL/RR, TT/TB/BT/BB) 페어 계산 로직과 8px 임계 우선순위 적용으로 해결 + +#### 다음 단계 +- 키보드 nudge(Shift/Alt 미세/가속)와 가이드 반영 TDD/구현 +- 다중 선택 및 그룹 이동/정렬 스펙 초안 TDD +- 접근성: 키보드 조작 시 라이브 리전 안내/가이드라인 ARIA 노출 정책 검토 + ## 목표 - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. @@ -246,6 +275,7 @@ - a11y/SEO/OG/Analytics ## 오류/버그 기록 +- [2025-11-16 22:32] jsdom 환경에서 Moveable 내부 `document.elementFromPoint` 미구현으로 예외 발생 → `Canvas.tsx`에 테스트 전용 폴리필 추가(`elementFromPoint: () => null`)로 해결. 린트 any 제거 위해 타입(`DocWithEFP`) 선언 및 미사용 인자 경고 최소화. - [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인 - [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지 - [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린 @@ -461,6 +491,14 @@ - 정책: 위치 8px 스냅, 회전 15° 스냅(Shift시 1° 미세제어) - 경계 보정: 0<|deg|<15 구간은 15°/−15°로 스냅되도록 조정 +- Moveable 통합 완료(TDD→구현→그린) + - 설치: `react-moveable`, `moveable` + - 스모크: Moveable가 드래그/리사이즈/회전 + 8px 스냅 그리드 props로 렌더 + - 이벤트 연동: onDrag/onResize/onRotate에서 `snap8`/`snapAngle` 적용, 가이드라인 표시/해제 + - Shift 1° 미세 제어 타입가드(`isShiftEvent`) 도입 + - target 바인딩: 선택 객체의 data-testid 셀렉터(`[data-testid="obj-"]`)를 Moveable `target`으로 전달 + + ### 다음 작업(캔버스 Moveable 통합) - 패키지 추가: `react-moveable`, `moveable` - TDD: Moveable 기반 드래그/리사이즈/회전 일원화 테스트 + 8px/15° 스냅 및 가이드라인 표시/해제 검증 diff --git a/components/wysiwyg/Canvas.guidelines.advanced.test.tsx b/components/wysiwyg/Canvas.guidelines.advanced.test.tsx new file mode 100644 index 0000000..d2b59e8 --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.advanced.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택이 되도록 첫 번째로 배치 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 대상(o2)의 수직 중심선은 x = 240 (x=200, w=80) + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (object-object snap)', () => { + beforeEach(() => capture.mockReset()) + + it('shows vertical guide and snaps x to other object center within 8px threshold', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + // 드래그 시작 → 가이드 활성화 상태 + await act(async () => { last.onDragStart?.() }) + + // o1의 중심을 o2 중심(240)에 근접시키도록 left를 220로 제안 + // o1 width=40이므로 중심은 left+20 → 240이 되도록 left=220 필요 + await act(async () => { last.onDrag?.({ left: 220, top: 10 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + // x 스냅 결과: left가 정확히 220으로 맞춰짐 + await waitFor(() => { + expect(obj.style.left).toBe('220px') + }) + // 가이드 라인 표시 + await screen.findByTestId('guide-x') + + // 드래그 종료 → 가이드 해제 + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.guidelines.edges.test.tsx b/components/wysiwyg/Canvas.guidelines.edges.test.tsx new file mode 100644 index 0000000..05c8dad --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.edges.test.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides-edges', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택되도록 첫 번째 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 기준(o2) - 좌/우 에지: 200, 280 + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (edge snap)', () => { + beforeEach(() => capture.mockReset()) + + it('snaps left edge to other object left edge within 8px threshold and shows vertical guide', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onDragStart?.() }) + + // o2.left = 200. o1을 205로 제안하면 임계(≤8px) 내 → 200으로 스냅 + await act(async () => { last.onDrag?.({ left: 205, top: 10 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.left).toBe('200px') + }) + + // 수직 가이드 라인 표시(에지 x=200) + await screen.findByTestId('guide-x') + + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx b/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx new file mode 100644 index 0000000..0c3c379 --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides-h', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택이 되도록 첫 번째 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 40, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 대상(o2) 수평 중심선: y = 65 (y=50, h=30) + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (horizontal center snap)', () => { + beforeEach(() => capture.mockReset()) + + it('shows horizontal guide and snaps y to other object center within 8px threshold', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onDragStart?.() }) + + // o1 height=40 → 중심은 top+20. o2 중심 65에 맞추려면 top=45 필요 + await act(async () => { last.onDrag?.({ left: 10, top: 45 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.top).toBe('45px') + }) + + // 수평 가이드 라인 표시 + await screen.findByTestId('guide-y') + + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-y')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.moveable.events.test.tsx b/components/wysiwyg/Canvas.moveable.events.test.tsx new file mode 100644 index 0000000..b9a969b --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.events.test.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() +type MProps = { + onDragStart?: (e: unknown) => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: (e: unknown) => void + onResize?: (e: { width?: number; height?: number }) => void + onRotate?: (e: { rotate?: number; inputEvent?: { shiftKey?: boolean } }) => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f1', + width: 400, + height: 300, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + { id: 'o1', type: 'text', x: 10, y: 10, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'T', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas + Moveable events', () => { + beforeEach(() => { + capture.mockReset() + }) + + it('onDrag applies 8px snap and updates left/top, guides show/hide', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + // start drag: guides visible + await act(async () => { last.onDragStart?.({}) }) + await screen.findByTestId('guide-x') + + // move: set left/top to 17/21 → snapped to 16/24 + await act(async () => { last.onDrag?.({ left: 17, top: 21 }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.left).toBe('16px') + expect(obj.style.top).toBe('24px') + }) + + // end: guides hidden + await act(async () => { last.onDragEnd?.({}) }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) + + it('onResize applies 8px snap and min size', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onResize?.({ width: 83, height: 13 }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.width).toBe('80px') + expect(obj.style.height).toBe('16px') + }) + }) + + it('onRotate applies 15deg snap (no shift) and 1deg with shift', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onRotate?.({ rotate: 7, inputEvent: { shiftKey: false } }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.transform).toContain('rotate(15deg)') + }) + + await act(async () => { last.onRotate?.({ rotate: 22, inputEvent: { shiftKey: true } }) }) + await waitFor(() => { + expect(obj.style.transform).toContain('rotate(22deg)') + }) + }) +}) diff --git a/components/wysiwyg/Canvas.moveable.target.test.tsx b/components/wysiwyg/Canvas.moveable.target.test.tsx new file mode 100644 index 0000000..d475553 --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.target.test.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-target', + width: 300, + height: 200, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + { id: 'o1', type: 'text', x: 8, y: 8, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#000' } }, + { id: 'o2', type: 'text', x: 96, y: 8, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas + Moveable target binding', () => { + beforeEach(() => { + capture.mockReset() + }) + + it('passes the selected object selector as Moveable target', () => { + const { getByTestId } = render() + + // 기본 선택은 첫 객체(o1)라고 가정 + const el = getByTestId('obj-o1') + const last = capture.mock.calls.at(-1)?.[0] as { target?: string } + expect(last).toBeTruthy() + expect(last?.target).toBe('[data-testid="obj-o1"]') + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 6c3ec42..fd97b35 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -1,11 +1,26 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' +import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap' function snap8(v: number) { return Math.round(v / 8) * 8 } +// Shift 키 타입 가드: unknown 입력에서 shiftKey 존재 여부를 안전하게 판별 +function isShiftEvent(ev: unknown): ev is { shiftKey?: boolean } { + return typeof ev === 'object' && ev !== null && 'shiftKey' in (ev as Record) +} + +// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가 +type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null } +if (typeof document !== 'undefined') { + const d = document as DocWithEFP + if (typeof d.elementFromPoint !== 'function') { + d.elementFromPoint = (_x: number, _y: number) => null + } +} + export type CanvasProps = { frame: Frame onChange?: (next: Frame) => void @@ -19,6 +34,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const [showGuides, setShowGuides] = useState(false) const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + // 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환) + const selectedElRef = useRef(null) const selected = useMemo(() => { for (const layer of model.layers) { @@ -130,6 +147,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { data-testid={`obj-${o.id}`} aria-label={`object ${o.id}`} onMouseDown={(e) => onMouseDownObj(e, o)} + ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined} style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedId===o.id? '2px solid #0ea5e9':'none' }} > {selectedId === o.id && ( @@ -150,12 +168,133 @@ export function Canvas({ frame, onChange }: CanvasProps) { )))} {/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */} { + // 드래그 시작 시 가이드 표시 + setShowGuides(true) + }} + onDrag={({ left, top }) => { + if (!selected) return + const rawLeft = typeof left === 'number' ? left : 0 + const rawTop = typeof top === 'number' ? top : 0 + let nx = snap8(rawLeft) + let ny = snap8(rawTop) + + // 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px + const threshold = 8 + let snappedCenterX: number | undefined + let snappedCenterY: number | undefined + // 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표 + let edgeNx: number | undefined + let edgeNy: number | undefined + let guideEdgeX: number | undefined + let guideEdgeY: number | undefined + let minDistX = Number.POSITIVE_INFINITY + let minDistY = Number.POSITIVE_INFINITY + outer: for (const layer of model.layers) { + for (const obj of layer.objects) { + if (obj.id === selected.id) continue + const otherCenterX = obj.x + obj.width / 2 + const selCenterX = rawLeft + selected.width / 2 + if (Math.abs(selCenterX - otherCenterX) <= threshold) { + snappedCenterX = otherCenterX + // 수평 중심도 동시에 체크 + const otherCenterY = obj.y + obj.height / 2 + const selCenterY = rawTop + selected.height / 2 + if (Math.abs(selCenterY - otherCenterY) <= threshold) { + snappedCenterY = otherCenterY + } + break outer + } + const otherCenterY2 = obj.y + obj.height / 2 + const selCenterY2 = rawTop + selected.height / 2 + if (Math.abs(selCenterY2 - otherCenterY2) <= threshold) { + snappedCenterY = otherCenterY2 + // x는 그리드, y만 스냅으로 나가고 루프 종료 + break outer + } + + // 에지 스냅 후보 계산 (X축) + const selLeft = rawLeft + const selRight = rawLeft + selected.width + const otherLeft = obj.x + const otherRight = obj.x + obj.width + const xPairs: Array<{dist: number; nx: number; guideX: number}> = [ + { dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L + { dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R + { dist: Math.abs(selRight - otherLeft), nx: otherLeft - selected.width, guideX: otherLeft }, // R-L + { dist: Math.abs(selRight - otherRight), nx: otherRight - selected.width, guideX: otherRight }, // R-R + ] + for (const p of xPairs) { + if (p.dist <= threshold && p.dist < minDistX) { + minDistX = p.dist + edgeNx = p.nx + guideEdgeX = p.guideX + } + } + + // 에지 스냅 후보 계산 (Y축) + const selTop = rawTop + const selBottom = rawTop + selected.height + const otherTop = obj.y + const otherBottom = obj.y + obj.height + const yPairs: Array<{dist: number; ny: number; guideY: number}> = [ + { dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T + { dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B + { dist: Math.abs(selBottom - otherTop), ny: otherTop - selected.height, guideY: otherTop }, // B-T + { dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selected.height, guideY: otherBottom }, // B-B + ] + for (const p of yPairs) { + if (p.dist <= threshold && p.dist < minDistY) { + minDistY = p.dist + edgeNy = p.ny + guideEdgeY = p.guideY + } + } + } + } + + // 우선순위: 에지 스냅 > 중심 스냅 > 그리드 + if (typeof edgeNx === 'number') { + nx = edgeNx + } else if (typeof snappedCenterX === 'number') { + nx = snappedCenterX - selected.width / 2 + } + if (typeof edgeNy === 'number') { + ny = edgeNy + } else if (typeof snappedCenterY === 'number') { + ny = snappedCenterY - selected.height / 2 + } + + // 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시 + const guideX = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx) + const guideY = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny) + setGuidePos({ x: guideX, y: guideY }) + + updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny })) + }} + onDragEnd={() => { + // 드래그 종료 시 가이드 숨김 + setShowGuides(false) + }} + onResize={({ width, height }) => { + if (!selected) return + const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0)) + const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0)) + updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh })) + }} + onRotate={({ rotate, inputEvent }) => { + if (!selected) return + const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey + const nd = snapAngleUtil(typeof rotate === 'number' ? rotate : 0, fine) + updateObject(selected.id, (o) => ({ ...o, rotate: nd })) + }} />
)