WYSIWYG: Moveable 가이드라인 고도화(중심/가장자리 스냅, 8px 임계) TDD/구현 및 테스트
This commit is contained in:
@@ -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