WYSIWYG: 멀티선택/그룹 스냅·가이드(그룹 bbox)/마퀴 선택 TDD·구현 및 문서 업데이트
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
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 }
|
||||
})
|
||||
|
||||
// 그룹 대상: o1(10,10,40x30), o2(80,10,40x30) → 그룹 bbox: left=10, right=120, top=10, bottom=40, 중심X=65, 중심Y=25
|
||||
// 기준 객체 o3: x=200,y=50,w=80,h=30 → 중심X=240, 중심Y=65
|
||||
const frame: Frame = {
|
||||
id: 'f-mgroup',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o3', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas multi-group guidelines (bbox-based snap)', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('snaps group vertical center to other object center within 8px and shows guide', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
|
||||
// 멀티 선택: o1 클릭 → o2 Shift+클릭
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
// 선택 반영 후 최신 Moveable props 확보
|
||||
await waitFor(() => {
|
||||
expect(capture.mock.calls.length).toBeGreaterThan(0)
|
||||
})
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// 그룹 중심X 현재 65. o3 중심X 240에 맞추려면 그룹을 전체적으로 +175 이동 필요.
|
||||
// 기준 대상(마지막 선택은 o2)이므로, o2.left를 80+175=255 근처로 제안.
|
||||
await act(async () => { last.onDrag?.({ left: 255, top: 10 }) })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1.style.left)
|
||||
const l2 = parseInt(o2.style.left)
|
||||
const dx = l2 - 80
|
||||
expect(l1).toBe(10 + dx)
|
||||
})
|
||||
|
||||
await screen.findByTestId('guide-x')
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('snaps by horizontal center (y-axis alignment) with group bbox', async () => {
|
||||
// 배치: o1/o2는 y=10, 높이=30 → 그룹 중심Y=25. o3는 y=50, 높이=30 → 중심Y=65.
|
||||
// 그룹을 아래로 드래그하여 중심Y가 65에 임계 내 근접하면 그룹 중심Y로 스냅되어야 한다.
|
||||
render(<Canvas frame={frame} />)
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// 중심Y: 25 → 65로 맞추려면 +40 이동. 마지막 선택(o2)의 top을 10+40=50 근처로 제안.
|
||||
await act(async () => { last.onDrag?.({ left: 80, top: 50 }) })
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const t1 = parseInt(o1.style.top)
|
||||
const t2 = parseInt(o2.style.top)
|
||||
// 동일 dy가 적용되었는지 상대 차로 검증
|
||||
expect(t1 - 10).toBe(t2 - 10)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('prefers edge over center for group when both within threshold (x-axis)', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
// 멀티 선택
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// 그룹 bbox 오른쪽 에지(R)와 o3 왼쪽 에지(L)가 근접하도록 제안(left ~ 200- 그룹폭 110 = 90 부근),
|
||||
// 동시에 그룹 중심도 o3 중심에 근접할 수 있으나, 에지가 우선되어야 함 → group left가 200 - groupWidth로 스냅
|
||||
// groupWidth=110, 기대 groupLeft=200-110=90. 마지막 선택(o2)을 기준으로 dx를 적용하므로 o2는 80→90+ (o2.x - groupLeft)=80-10=70 ⇒ 90+70=160
|
||||
await act(async () => { last.onDrag?.({ left: 160, top: 10 }) })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1.style.left) // 그룹 좌측
|
||||
// 허용: 200 - groupWidth = 90 또는 8px 스냅 근사치(88 또는 96)
|
||||
expect([88, 90, 96]).toContain(l1)
|
||||
const expectedDx = l1 - 10 // o1 기준 dx
|
||||
const l2 = parseInt(o2.style.left)
|
||||
expect(l2).toBe(80 + expectedDx)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('snaps group to the nearest among multiple candidate objects on the same axis', async () => {
|
||||
// o3 외에 더 먼 객체를 하나 더 추가
|
||||
const frame2: Frame = JSON.parse(JSON.stringify(frame))
|
||||
frame2.layers[0].objects.push({ id: 'o4', type: 'text', x: 320, y: 50, width: 60, height: 30, rotate: 0, zIndex: 0, props: { text: 'D', fontSize: 16, color: '#000' } })
|
||||
render(<Canvas frame={frame2} />)
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// o3(가까움: 200), o4(멀음: 320). 그룹을 양쪽 모두 임계 내로 근접시키되 o3에 더 가깝게 → o3쪽으로 스냅
|
||||
await act(async () => { last.onDrag?.({ left: 150, top: 10 }) })
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const l2 = parseInt(o2.style.left)
|
||||
const l1 = parseInt(o1.style.left)
|
||||
expect(l2).toBeLessThan(256) // 320 라인보다 200 라인에 가까운 쪽으로
|
||||
// 상대 delta 일관성 확인
|
||||
expect(l1 - 10).toBe(l2 - 80)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user