auto: PR for feat/wysiwyg-moveable-integration #71
@@ -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-<id>"]`)를 Moveable `target`으로 전달
|
||||
|
||||
|
||||
### 다음 작업(캔버스 Moveable 통합)
|
||||
- 패키지 추가: `react-moveable`, `moveable`
|
||||
- TDD: Moveable 기반 드래그/리사이즈/회전 일원화 테스트 + 8px/15° 스냅 및 가이드라인 표시/해제 검증
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
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(<Canvas frame={frame} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
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(<Canvas frame={frame} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
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(<Canvas frame={frame} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
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(<Canvas frame={frame} />)
|
||||
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(<Canvas frame={frame} />)
|
||||
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(<Canvas frame={frame} />)
|
||||
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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
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(<Canvas frame={frame} />)
|
||||
|
||||
// 기본 선택은 첫 객체(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"]')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>)
|
||||
}
|
||||
|
||||
// 테스트 환경(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<HTMLDivElement | null>(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 스냅 그리드 활성화 */}
|
||||
<Moveable
|
||||
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
|
||||
draggable
|
||||
resizable
|
||||
rotatable
|
||||
snappable
|
||||
snapGridWidth={8}
|
||||
snapGridHeight={8}
|
||||
onDragStart={() => {
|
||||
// 드래그 시작 시 가이드 표시
|
||||
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 }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user