WYSIWYG: 멀티선택/그룹 스냅·가이드(그룹 bbox)/마퀴 선택 TDD·구현 및 문서 업데이트

This commit is contained in:
2025-11-17 00:46:54 +09:00
parent 302ab79dd4
commit 498ffb675a
8 changed files with 571 additions and 36 deletions
+135 -34
View File
@@ -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={() => {
// 드래그 종료 시 가이드 숨김