WYSIWYG: 멀티선택/그룹 스냅·가이드(그룹 bbox)/마퀴 선택 TDD·구현 및 문서 업데이트
This commit is contained in:
+135
-34
@@ -17,7 +17,7 @@ type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Elem
|
||||
if (typeof document !== 'undefined') {
|
||||
const d = document as DocWithEFP
|
||||
if (typeof d.elementFromPoint !== 'function') {
|
||||
d.elementFromPoint = (_x: number, _y: number) => null
|
||||
d.elementFromPoint = (_x: number, _y: number) => { void _x; void _y; return null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,21 @@ export type CanvasProps = {
|
||||
|
||||
export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(model.layers[0]?.objects[0]?.id ?? null)
|
||||
const initialId = model.layers[0]?.objects[0]?.id ?? null
|
||||
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 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 })
|
||||
const [marquee, setMarquee] = useState<{
|
||||
active: boolean
|
||||
x0: number
|
||||
y0: number
|
||||
x1: number
|
||||
y1: number
|
||||
}>({ active: false, x0: 0, y0: 0, x1: 0, y1: 0 })
|
||||
// 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환)
|
||||
const selectedElRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -53,6 +62,21 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
const updateObjects = useCallback((ids: string[], updater: (o: WObject) => WObject) => {
|
||||
setModel((prev) => {
|
||||
const idSet = new Set(ids)
|
||||
const next: Frame = {
|
||||
...prev,
|
||||
layers: prev.layers.map((ly) => ({
|
||||
...ly,
|
||||
objects: ly.objects.map((o) => (idSet.has(o.id) ? updater(o) : o)),
|
||||
})),
|
||||
}
|
||||
onChange?.(next)
|
||||
return next
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!selected) return
|
||||
let dx = 0, dy = 0
|
||||
@@ -78,13 +102,45 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
}, [selected, updateObject])
|
||||
|
||||
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
setSelectedId(o.id)
|
||||
if (e.shiftKey) {
|
||||
setSelectedIds((prev) => {
|
||||
const exists = prev.includes(o.id)
|
||||
if (exists) return prev.filter((id) => id !== o.id)
|
||||
return [...prev, o.id]
|
||||
})
|
||||
} else {
|
||||
setSelectedIds([o.id])
|
||||
}
|
||||
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) })
|
||||
}, [])
|
||||
|
||||
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
|
||||
// 마퀴 선택 처리 우선
|
||||
if (marquee.active) {
|
||||
const x1 = e.clientX
|
||||
const y1 = e.clientY
|
||||
setMarquee((m) => ({ ...m, x1, y1 }))
|
||||
// 교차하는 객체 선택 갱신
|
||||
const minX = Math.min(marquee.x0, x1)
|
||||
const minY = Math.min(marquee.y0, y1)
|
||||
const maxX = Math.max(marquee.x0, x1)
|
||||
const maxY = Math.max(marquee.y0, y1)
|
||||
const hits: string[] = []
|
||||
for (const ly of model.layers) {
|
||||
for (const o of ly.objects) {
|
||||
const ol = o.x
|
||||
const ot = o.y
|
||||
const or = o.x + o.width
|
||||
const ob = o.y + o.height
|
||||
const inter = !(or < minX || ob < minY || ol > maxX || ot > maxY)
|
||||
if (inter) hits.push(o.id)
|
||||
}
|
||||
}
|
||||
setSelectedIds(hits)
|
||||
return
|
||||
}
|
||||
const rot = rotatingRef.current
|
||||
if (rot) {
|
||||
const dx = e.clientX - rot.startX
|
||||
@@ -113,14 +169,17 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
setGuidePos({ x: nx, y: ny })
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}, [updateObject])
|
||||
}, [updateObject, marquee.active, marquee.x0, marquee.y0, model.layers])
|
||||
|
||||
const onMouseUpCanvas = useCallback(() => {
|
||||
if (marquee.active) {
|
||||
setMarquee((m) => ({ ...m, active: false }))
|
||||
}
|
||||
draggingRef.current = null
|
||||
resizingRef.current = null
|
||||
rotatingRef.current = null
|
||||
setShowGuides(false)
|
||||
}, [])
|
||||
}, [marquee.active])
|
||||
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
e.stopPropagation()
|
||||
@@ -138,6 +197,14 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
aria-label="WYSIWYG Canvas"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => {
|
||||
// 빈 캔버스 클릭 시 마퀴 시작 또는 선택 해제
|
||||
// 객체 onMouseDown에서 선택을 처리하므로, 여기서는 드래그 박스 시작만 담당
|
||||
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
|
||||
setSelectedIds([])
|
||||
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
|
||||
}
|
||||
}}
|
||||
onMouseMove={onMouseMoveCanvas}
|
||||
onMouseUp={onMouseUpCanvas}
|
||||
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
|
||||
@@ -156,16 +223,16 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
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' }}
|
||||
style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none' }}
|
||||
>
|
||||
{selectedId === o.id && (
|
||||
{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' }}
|
||||
/>
|
||||
)}
|
||||
{selectedId === o.id && (
|
||||
{selectedIds.includes(o.id) && (
|
||||
<div
|
||||
data-testid={`rotate-handle-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownRotate(e, o)}
|
||||
@@ -189,8 +256,39 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
}}
|
||||
onDrag={({ left, top }) => {
|
||||
if (!selected) return
|
||||
const rawLeft = typeof left === 'number' ? left : 0
|
||||
const rawTop = typeof top === 'number' ? top : 0
|
||||
const rawLeftInput = typeof left === 'number' ? left : 0
|
||||
const rawTopInput = typeof top === 'number' ? top : 0
|
||||
// 다중 선택 시: 그룹 bbox 기준으로 스냅 계산
|
||||
let baseLeft = selected.x
|
||||
let baseTop = selected.y
|
||||
let rawLeft = rawLeftInput
|
||||
let rawTop = rawTopInput
|
||||
let groupLeft = selected.x
|
||||
let groupTop = selected.y
|
||||
let groupWidth = selected.width
|
||||
let groupHeight = selected.height
|
||||
if (selectedIds.length > 1) {
|
||||
let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity
|
||||
for (const ly of model.layers) {
|
||||
for (const o of ly.objects) {
|
||||
if (!selectedIds.includes(o.id)) continue
|
||||
minL = Math.min(minL, o.x)
|
||||
minT = Math.min(minT, o.y)
|
||||
maxR = Math.max(maxR, o.x + o.width)
|
||||
maxB = Math.max(maxB, o.y + o.height)
|
||||
}
|
||||
}
|
||||
groupLeft = minL
|
||||
groupTop = minT
|
||||
groupWidth = Math.max(0, maxR - minL)
|
||||
groupHeight = Math.max(0, maxB - minT)
|
||||
const dxProp = rawLeftInput - selected.x
|
||||
const dyProp = rawTopInput - selected.y
|
||||
baseLeft = groupLeft
|
||||
baseTop = groupTop
|
||||
rawLeft = groupLeft + dxProp
|
||||
rawTop = groupTop + dyProp
|
||||
}
|
||||
let nx = snap8(rawLeft)
|
||||
let ny = snap8(rawTop)
|
||||
|
||||
@@ -198,6 +296,8 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
const threshold = 8
|
||||
let snappedCenterX: number | undefined
|
||||
let snappedCenterY: number | undefined
|
||||
let centerDistX = Number.POSITIVE_INFINITY
|
||||
let centerDistY = Number.POSITIVE_INFINITY
|
||||
// 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표
|
||||
let edgeNx: number | undefined
|
||||
let edgeNy: number | undefined
|
||||
@@ -205,39 +305,34 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
let guideEdgeY: number | undefined
|
||||
let minDistX = Number.POSITIVE_INFINITY
|
||||
let minDistY = Number.POSITIVE_INFINITY
|
||||
outer: for (const layer of model.layers) {
|
||||
for (const layer of model.layers) {
|
||||
for (const obj of layer.objects) {
|
||||
if (obj.id === selected.id) continue
|
||||
if (selectedIds.includes(obj.id)) continue
|
||||
const otherCenterX = obj.x + obj.width / 2
|
||||
const selCenterX = rawLeft + selected.width / 2
|
||||
if (Math.abs(selCenterX - otherCenterX) <= threshold) {
|
||||
const selCenterX = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width) / 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 + 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) {
|
||||
const selCenterY2 = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height) / 2
|
||||
const dyCenter = Math.abs(selCenterY2 - otherCenterY2)
|
||||
if (dyCenter <= threshold && dyCenter < centerDistY) {
|
||||
centerDistY = dyCenter
|
||||
snappedCenterY = otherCenterY2
|
||||
// x는 그리드, y만 스냅으로 나가고 루프 종료
|
||||
break outer
|
||||
}
|
||||
|
||||
// 에지 스냅 후보 계산 (X축)
|
||||
const selLeft = rawLeft
|
||||
const selRight = rawLeft + selected.width
|
||||
const selRight = rawLeft + (selectedIds.length > 1 ? groupWidth : 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
|
||||
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherLeft }, // R-L
|
||||
{ dist: Math.abs(selRight - otherRight), nx: otherRight - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherRight }, // R-R
|
||||
]
|
||||
for (const p of xPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistX) {
|
||||
@@ -249,14 +344,14 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
|
||||
// 에지 스냅 후보 계산 (Y축)
|
||||
const selTop = rawTop
|
||||
const selBottom = rawTop + selected.height
|
||||
const selBottom = rawTop + (selectedIds.length > 1 ? groupHeight : 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
|
||||
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherTop }, // B-T
|
||||
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherBottom }, // B-B
|
||||
]
|
||||
for (const p of yPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistY) {
|
||||
@@ -272,12 +367,12 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
if (typeof edgeNx === 'number') {
|
||||
nx = edgeNx
|
||||
} else if (typeof snappedCenterX === 'number') {
|
||||
nx = snappedCenterX - selected.width / 2
|
||||
nx = snappedCenterX - (selectedIds.length > 1 ? groupWidth : selected.width) / 2
|
||||
}
|
||||
if (typeof edgeNy === 'number') {
|
||||
ny = edgeNy
|
||||
} else if (typeof snappedCenterY === 'number') {
|
||||
ny = snappedCenterY - selected.height / 2
|
||||
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
|
||||
}
|
||||
|
||||
// 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
|
||||
@@ -285,7 +380,13 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
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 }))
|
||||
if (selectedIds.length > 1) {
|
||||
const dx = nx - baseLeft
|
||||
const dy = ny - baseTop
|
||||
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + dx, y: o.y + dy }))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// 드래그 종료 시 가이드 숨김
|
||||
|
||||
Reference in New Issue
Block a user