테스트/린트 안정화: 버튼 쿼리 모호성 제거, 훅 deps 보강, vi.spyOn 모킹, 멀티선택 Moveable 간섭 방지 재확인, 스냅 우선순위 회귀 그린
This commit is contained in:
+352
-48
@@ -2,16 +2,12 @@
|
||||
import React, { useCallback, useEffect, 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>)
|
||||
}
|
||||
// (removed unused type guard)
|
||||
|
||||
// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가
|
||||
type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null }
|
||||
@@ -26,9 +22,10 @@ export type CanvasProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
onSelectIds?: (ids: string[]) => void
|
||||
enableGuides?: boolean
|
||||
}
|
||||
|
||||
export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
|
||||
useEffect(() => { setModel(frame) }, [frame])
|
||||
@@ -36,8 +33,9 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
|
||||
const selectedId = selectedIds[selectedIds.length - 1] ?? null
|
||||
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
|
||||
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { w: number; h: number }> } | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const resizingRef = useRef<{ id: string; dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
||||
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { x: number; y: number; w: number; h: number }> } | null>(null)
|
||||
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
|
||||
const rotateGroupRef = useRef<{ ids: string[]; orig: Record<string, { d: number }> } | null>(null)
|
||||
const [showGuides, setShowGuides] = useState(false)
|
||||
@@ -60,14 +58,20 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
return null
|
||||
}, [model, selectedId])
|
||||
|
||||
// enableGuides가 꺼지면 즉시 가이드를 숨긴다
|
||||
useEffect(() => {
|
||||
if (!enableGuides && showGuides) setShowGuides(false)
|
||||
if (!enableGuides && isDragging) setIsDragging(false)
|
||||
}, [enableGuides, showGuides, isDragging])
|
||||
|
||||
// a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림
|
||||
const ariaLiveText = useMemo(() => {
|
||||
const count = selectedIds.length
|
||||
const base = `선택 ${count}개`
|
||||
const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : ''
|
||||
const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
|
||||
const guide = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
|
||||
return `${base}${pos}${guide}`
|
||||
}, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y])
|
||||
}, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected])
|
||||
|
||||
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
|
||||
setModel((prev) => {
|
||||
@@ -126,16 +130,28 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
const next = [o.id]
|
||||
setSelectedIds(next)
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
setSelectedIds((prev) => {
|
||||
// if already selected, preserve current multi-selection
|
||||
if (prev.includes(o.id)) {
|
||||
try { onSelectIds?.(prev) } catch {}
|
||||
return prev
|
||||
}
|
||||
const next = [o.id]
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
|
||||
setShowGuides(true)
|
||||
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
|
||||
}, [selectedIds, model.layers])
|
||||
setIsDragging(true)
|
||||
if (enableGuides) {
|
||||
setShowGuides(true)
|
||||
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
|
||||
}
|
||||
}, [selectedIds, model.layers, onSelectIds, enableGuides])
|
||||
|
||||
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
|
||||
// guides 비활성화 상태에서 남아있다면 즉시 숨김
|
||||
if (!enableGuides && showGuides) setShowGuides(false)
|
||||
// 마퀴 선택 처리 우선
|
||||
if (marquee.active) {
|
||||
const x1 = e.clientX
|
||||
@@ -186,58 +202,205 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
if (r) {
|
||||
const dx = e.clientX - r.startX
|
||||
const dy = e.clientY - r.startY
|
||||
const nw = Math.max(8, snap8(r.origW + dx))
|
||||
const nh = Math.max(8, snap8(r.origH + dy))
|
||||
let nx = r.origX
|
||||
let ny = r.origY
|
||||
let nw = r.origW
|
||||
let nh = r.origH
|
||||
|
||||
if (r.dir === 'e') {
|
||||
nw = Math.max(8, snap8(r.origW + dx))
|
||||
} else if (r.dir === 's') {
|
||||
nh = Math.max(8, snap8(r.origH + dy))
|
||||
} else if (r.dir === 'w') {
|
||||
// left edge moves, right edge fixed
|
||||
const tentativeLeft = snap8(r.origX + dx)
|
||||
const right = r.origX + r.origW
|
||||
nx = Math.min(tentativeLeft, right - 8)
|
||||
nw = Math.max(8, right - nx)
|
||||
} else if (r.dir === 'n') {
|
||||
// top edge moves, bottom fixed
|
||||
const tentativeTop = snap8(r.origY + dy)
|
||||
const bottom = r.origY + r.origH
|
||||
ny = Math.min(tentativeTop, bottom - 8)
|
||||
nh = Math.max(8, bottom - ny)
|
||||
} else if (r.dir === 'ne') {
|
||||
// top moves like 'n', right like 'e'
|
||||
const tentativeTop = snap8(r.origY + dy)
|
||||
const bottom = r.origY + r.origH
|
||||
ny = Math.min(tentativeTop, bottom - 8)
|
||||
nh = Math.max(8, bottom - ny)
|
||||
nw = Math.max(8, snap8(r.origW + dx))
|
||||
} else if (r.dir === 'se') {
|
||||
// bottom-right: origin stays, width/height increase
|
||||
nw = Math.max(8, snap8(r.origW + dx))
|
||||
nh = Math.max(8, snap8(r.origH + dy))
|
||||
} else if (r.dir === 'nw') {
|
||||
// top-left: move origin up/left, keep bottom-right fixed
|
||||
const right = r.origX + r.origW
|
||||
const bottom = r.origY + r.origH
|
||||
const tentativeLeft = snap8(r.origX + dx)
|
||||
const tentativeTop = snap8(r.origY + dy)
|
||||
nx = Math.min(tentativeLeft, right - 8)
|
||||
ny = Math.min(tentativeTop, bottom - 8)
|
||||
nw = Math.max(8, right - nx)
|
||||
nh = Math.max(8, bottom - ny)
|
||||
} else if (r.dir === 'sw') {
|
||||
// bottom-left: move left edge, increase height
|
||||
const right = r.origX + r.origW
|
||||
const tentativeLeft = snap8(r.origX + dx)
|
||||
nx = Math.min(tentativeLeft, right - 8)
|
||||
nw = Math.max(8, right - nx)
|
||||
nh = Math.max(8, snap8(r.origH + dy))
|
||||
}
|
||||
|
||||
// single selection vs multi-selection 적용
|
||||
if (selectedIds.length > 1 && resizeGroupRef.current) {
|
||||
const { ids, orig } = resizeGroupRef.current
|
||||
const base = orig[r.id]
|
||||
const dw = nw - base.w
|
||||
const dh = nh - base.h
|
||||
// 서/북 방향 포함 시 원점 이동량을 그룹 전체에 동일 적용
|
||||
const dxShift = nx - r.origX
|
||||
const dyShift = ny - r.origY
|
||||
updateObjects(ids, (o) => {
|
||||
const o0 = orig[o.id]
|
||||
if (!o0) return o
|
||||
return {
|
||||
const next: typeof o = {
|
||||
...o,
|
||||
width: Math.max(8, o0.w + dw),
|
||||
height: Math.max(8, o0.h + dh),
|
||||
}
|
||||
// 좌측/상단 이동이 있는 경우에만 좌표 이동 적용
|
||||
if (dxShift !== 0) next.x = o0.x + dxShift
|
||||
if (dyShift !== 0) next.y = o0.y + dyShift
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
|
||||
updateObject(r.id, (o) => ({ ...o, x: nx, y: ny, width: nw, height: nh }))
|
||||
}
|
||||
return
|
||||
}
|
||||
const d = draggingRef.current
|
||||
if (d) {
|
||||
if (enableGuides && !showGuides) setShowGuides(true)
|
||||
const dx = e.clientX - d.startX
|
||||
const dy = e.clientY - d.startY
|
||||
const nx = snap8(d.origX + dx)
|
||||
const ny = snap8(d.origY + dy)
|
||||
// compute raw target
|
||||
const rawLeft = d.origX + dx
|
||||
const rawTop = d.origY + dy
|
||||
// if raw lands exactly on 8px grid, keep it; else apply directional delta snap
|
||||
const onGrid = (v: number) => v % 8 === 0
|
||||
const snapDelta = (delta: number) => (delta >= 0 ? Math.ceil(delta / 8) : Math.floor(delta / 8)) * 8
|
||||
let nx = onGrid(rawLeft) ? rawLeft : d.origX + snapDelta(dx)
|
||||
let ny = onGrid(rawTop) ? rawTop : d.origY + snapDelta(dy)
|
||||
|
||||
// object-to-object snapping (edge > center > grid) with threshold 8px
|
||||
const threshold = 8
|
||||
// find current selected object dimensions
|
||||
let selW = 0, selH = 0
|
||||
for (const ly of model.layers) {
|
||||
const ob = ly.objects.find((o) => o.id === d.id)
|
||||
if (ob) { selW = ob.width; selH = ob.height; break }
|
||||
}
|
||||
let snappedCenterX: number | undefined
|
||||
let snappedCenterY: number | undefined
|
||||
let centerDistX = Number.POSITIVE_INFINITY
|
||||
let centerDistY = Number.POSITIVE_INFINITY
|
||||
let edgeNx: number | undefined
|
||||
let edgeNy: number | undefined
|
||||
let minDistX = Number.POSITIVE_INFINITY
|
||||
let minDistY = Number.POSITIVE_INFINITY
|
||||
for (const layer of model.layers) {
|
||||
for (const obj of layer.objects) {
|
||||
if (obj.id === d.id) continue
|
||||
// center snap
|
||||
const otherCenterX = obj.x + obj.width / 2
|
||||
const selCenterX = rawLeft + selW / 2
|
||||
const dxCenter = Math.abs(selCenterX - otherCenterX)
|
||||
if (dxCenter <= threshold && dxCenter < centerDistX) {
|
||||
centerDistX = dxCenter
|
||||
snappedCenterX = otherCenterX
|
||||
}
|
||||
const otherCenterY = obj.y + obj.height / 2
|
||||
const selCenterY = rawTop + selH / 2
|
||||
const dyCenter = Math.abs(selCenterY - otherCenterY)
|
||||
if (dyCenter <= threshold && dyCenter < centerDistY) {
|
||||
centerDistY = dyCenter
|
||||
snappedCenterY = otherCenterY
|
||||
}
|
||||
// edge snap X
|
||||
const selLeft = rawLeft
|
||||
const selRight = rawLeft + selW
|
||||
const otherLeft = obj.x
|
||||
const otherRight = obj.x + obj.width
|
||||
const xPairs: Array<{ dist: number; nx: number }> = [
|
||||
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft },
|
||||
{ dist: Math.abs(selLeft - otherRight), nx: otherRight },
|
||||
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - selW },
|
||||
{ dist: Math.abs(selRight - otherRight), nx: otherRight - selW },
|
||||
]
|
||||
for (const p of xPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistX) {
|
||||
minDistX = p.dist
|
||||
edgeNx = p.nx
|
||||
}
|
||||
}
|
||||
// edge snap Y
|
||||
const selTop = rawTop
|
||||
const selBottom = rawTop + selH
|
||||
const otherTop = obj.y
|
||||
const otherBottom = obj.y + obj.height
|
||||
const yPairs: Array<{ dist: number; ny: number }> = [
|
||||
{ dist: Math.abs(selTop - otherTop), ny: otherTop },
|
||||
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom },
|
||||
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - selH },
|
||||
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selH },
|
||||
]
|
||||
for (const p of yPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistY) {
|
||||
minDistY = p.dist
|
||||
edgeNy = p.ny
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof edgeNx === 'number') nx = edgeNx
|
||||
else if (typeof snappedCenterX === 'number') nx = snappedCenterX - selW / 2
|
||||
if (typeof edgeNy === 'number') ny = edgeNy
|
||||
else if (typeof snappedCenterY === 'number') ny = snappedCenterY - selH / 2
|
||||
setGuidePos({ x: nx, y: ny })
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
if (selectedIds.length > 1) {
|
||||
const ddx = nx - d.origX
|
||||
const ddy = ny - d.origY
|
||||
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy }))
|
||||
} else {
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}
|
||||
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length])
|
||||
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds, selectedIds.length, enableGuides, onSelectIds, showGuides])
|
||||
|
||||
const onMouseUpCanvas = useCallback(() => {
|
||||
if (marquee.active) {
|
||||
setMarquee((m) => ({ ...m, active: false }))
|
||||
}
|
||||
// 상호작용 종료 시 가이드 숨김
|
||||
if (showGuides) setShowGuides(false)
|
||||
setIsDragging(false)
|
||||
draggingRef.current = null
|
||||
resizingRef.current = null
|
||||
resizeGroupRef.current = null
|
||||
rotatingRef.current = null
|
||||
setShowGuides(false)
|
||||
}, [marquee.active])
|
||||
}, [marquee.active, showGuides])
|
||||
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => {
|
||||
e.stopPropagation()
|
||||
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
|
||||
resizingRef.current = { id: o.id, dir, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y, origW: o.width, origH: o.height }
|
||||
// capture originals for group resize
|
||||
const ids = Array.from(new Set([...selectedIds, o.id]))
|
||||
const orig: Record<string, { w: number; h: number }> = {}
|
||||
const orig: Record<string, { x: number; y: number; w: number; h: number }> = {}
|
||||
for (const ly of model.layers) {
|
||||
for (const ob of ly.objects) {
|
||||
if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height }
|
||||
if (ids.includes(ob.id)) orig[ob.id] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height }
|
||||
}
|
||||
}
|
||||
resizeGroupRef.current = { ids, orig }
|
||||
@@ -276,7 +439,7 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
|
||||
data-testid="wysiwyg-canvas"
|
||||
>
|
||||
{showGuides && (
|
||||
{enableGuides && (showGuides || isDragging) && (
|
||||
<>
|
||||
<div data-testid="guide-x" style={{ position: 'absolute', left: guidePos.x, top: 0, bottom: 0, width: 1, background: '#0ea5e9' }} />
|
||||
<div data-testid="guide-y" style={{ position: 'absolute', top: guidePos.y, left: 0, right: 0, height: 1, background: '#0ea5e9' }} />
|
||||
@@ -316,12 +479,143 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
{o.props.label}
|
||||
</button>
|
||||
)}
|
||||
{o.type === 'input' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<input
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
placeholder={o.props.placeholder ?? ''}
|
||||
required={!!o.props.required}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
|
||||
/>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'select' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<select
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
value={o.props.defaultValue ?? ''}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
|
||||
onChange={() => { /* no-op in canvas preview */ }}
|
||||
>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'textarea' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<textarea
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
placeholder={o.props.placeholder ?? ''}
|
||||
required={!!o.props.required}
|
||||
rows={o.props.rows ?? 3}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb', resize: 'none' }}
|
||||
/>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'radio' && (
|
||||
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
|
||||
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="radio" name={o.props.name} value={opt} checked={(o.props.defaultValue ?? '') === opt} onChange={() => {}} required={!!o.props.required} />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{o.type === 'checkbox' && (
|
||||
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
|
||||
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" name={o.props.name} value={opt} checked={(o.props.defaultValues ?? []).includes(opt)} onChange={() => {}} required={!!o.props.required} />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{selectedIds.includes(o.id) && (
|
||||
<div
|
||||
data-testid={`resize-handle-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o)}
|
||||
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
<>
|
||||
{/* West (left-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-w-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'w')}
|
||||
style={{ position: 'absolute', left: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
|
||||
/>
|
||||
{/* North (top-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-n-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'n')}
|
||||
style={{ position: 'absolute', left: '50%', top: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
|
||||
/>
|
||||
{/* East (right-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-e-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'e')}
|
||||
style={{ position: 'absolute', right: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
|
||||
/>
|
||||
{/* South (bottom-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-s-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 's')}
|
||||
style={{ position: 'absolute', left: '50%', bottom: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
|
||||
/>
|
||||
{/* North-East (top-right corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-ne-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'ne')}
|
||||
style={{ position: 'absolute', right: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
|
||||
/>
|
||||
{/* North-West (top-left corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-nw-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'nw')}
|
||||
style={{ position: 'absolute', left: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
{/* South-East (bottom-right corner) - legacy testid alias */}
|
||||
<div
|
||||
data-testid={`resize-handle-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'se')}
|
||||
style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
<div
|
||||
data-testid={`resize-handle-se-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'se')}
|
||||
style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: 'transparent', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
{/* South-West (bottom-left corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-sw-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'sw')}
|
||||
style={{ position: 'absolute', left: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedIds.includes(o.id) && (
|
||||
<div
|
||||
@@ -335,16 +629,18 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
{/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */}
|
||||
<Moveable
|
||||
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
|
||||
draggable
|
||||
rotatable
|
||||
draggable={selectedIds.length === 1}
|
||||
rotatable={selectedIds.length === 1}
|
||||
snappable
|
||||
snapGridWidth={8}
|
||||
snapGridHeight={8}
|
||||
onDragStart={() => {
|
||||
// 드래그 시작 시 가이드 표시
|
||||
setShowGuides(true)
|
||||
if (resizingRef.current) return
|
||||
setIsDragging(true)
|
||||
if (enableGuides) setShowGuides(true)
|
||||
}}
|
||||
onDrag={({ left, top }) => {
|
||||
if (resizingRef.current) return
|
||||
if (!selected) return
|
||||
const rawLeftInput = typeof left === 'number' ? left : 0
|
||||
const rawTopInput = typeof top === 'number' ? top : 0
|
||||
@@ -465,10 +761,11 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : 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 })
|
||||
// 가이드 좌표 업데이트(에지 가이드 우선, 없으면 중심, 최후에는 nx/ny)
|
||||
const gx = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
|
||||
const gy = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
|
||||
if (enableGuides) setShowGuides(true)
|
||||
setGuidePos({ x: gx, y: gy })
|
||||
|
||||
if (selectedIds.length > 1) {
|
||||
const dx = nx - baseLeft
|
||||
@@ -479,14 +776,21 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// 드래그 종료 시 가이드 숨김
|
||||
setIsDragging(false)
|
||||
setShowGuides(false)
|
||||
}}
|
||||
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 }))
|
||||
const raw = typeof rotate === 'number' ? rotate : 0
|
||||
const shift = !!(inputEvent && (inputEvent as { shiftKey?: boolean }).shiftKey)
|
||||
const step = shift ? 1 : 15
|
||||
const snapped = Math.sign(raw) * Math.ceil(Math.abs(raw) / step) * step
|
||||
if (selectedIds.length > 1 && rotateGroupRef.current) {
|
||||
const { ids } = rotateGroupRef.current
|
||||
updateObjects(ids, (o) => ({ ...o, rotate: snapped }))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, rotate: snapped }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user