411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
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) => { void _x; void _y; return null }
|
|
}
|
|
}
|
|
|
|
export type CanvasProps = {
|
|
frame: Frame
|
|
onChange?: (next: Frame) => void
|
|
}
|
|
|
|
export function Canvas({ frame, onChange }: CanvasProps) {
|
|
const [model, setModel] = useState<Frame>(frame)
|
|
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)
|
|
|
|
const selected = useMemo(() => {
|
|
for (const layer of model.layers) {
|
|
const obj = layer.objects.find((o) => o.id === selectedId)
|
|
if (obj) return obj
|
|
}
|
|
return null
|
|
}, [model, selectedId])
|
|
|
|
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
|
|
setModel((prev) => {
|
|
const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) }
|
|
onChange?.(next)
|
|
return next
|
|
})
|
|
}, [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
|
|
let step = 1
|
|
let useSnap = true
|
|
// Shift: 미세 이동(1px, 스냅 없음), Alt: 가속 이동(16px, 스냅 적용)
|
|
if (e.shiftKey) { step = 1; useSnap = false }
|
|
else if (e.altKey) { step = 16; useSnap = true }
|
|
|
|
if (e.key === 'ArrowLeft') dx = -step
|
|
else if (e.key === 'ArrowRight') dx = step
|
|
else if (e.key === 'ArrowUp') dy = -step
|
|
else if (e.key === 'ArrowDown') dy = step
|
|
else return
|
|
e.preventDefault()
|
|
updateObject(selected.id, (o) => {
|
|
const tx = o.x + dx
|
|
const ty = o.y + dy
|
|
const nx = useSnap ? snap8(tx) : tx
|
|
const ny = useSnap ? snap8(ty) : ty
|
|
return { ...o, x: nx, y: ny }
|
|
})
|
|
}, [selected, updateObject])
|
|
|
|
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
|
|
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
|
|
let next = rot.origDeg + dx
|
|
if (!e.shiftKey) {
|
|
next = Math.round(next / 15) * 15
|
|
}
|
|
updateObject(rot.id, (o) => ({ ...o, rotate: next }))
|
|
return
|
|
}
|
|
const r = resizingRef.current
|
|
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))
|
|
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
|
|
return
|
|
}
|
|
const d = draggingRef.current
|
|
if (d) {
|
|
const dx = e.clientX - d.startX
|
|
const dy = e.clientY - d.startY
|
|
const nx = snap8(d.origX + dx)
|
|
const ny = snap8(d.origY + dy)
|
|
setGuidePos({ x: nx, y: ny })
|
|
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
|
}
|
|
}, [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()
|
|
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
|
|
}, [])
|
|
|
|
const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => {
|
|
e.stopPropagation()
|
|
rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate }
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
role="application"
|
|
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' }}
|
|
data-testid="wysiwyg-canvas"
|
|
>
|
|
{showGuides && (
|
|
<>
|
|
<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' }} />
|
|
</>
|
|
)}
|
|
{model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
|
|
<div
|
|
key={o.id}
|
|
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: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none' }}
|
|
>
|
|
{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' }}
|
|
/>
|
|
)}
|
|
{selectedIds.includes(o.id) && (
|
|
<div
|
|
data-testid={`rotate-handle-${o.id}`}
|
|
onMouseDown={(e) => onMouseDownRotate(e, o)}
|
|
style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)))}
|
|
{/* 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 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)
|
|
|
|
// 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px
|
|
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
|
|
let guideEdgeX: number | undefined
|
|
let guideEdgeY: 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 (selectedIds.includes(obj.id)) continue
|
|
const otherCenterX = obj.x + obj.width / 2
|
|
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 otherCenterY2 = obj.y + obj.height / 2
|
|
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축)
|
|
const selLeft = rawLeft
|
|
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 - (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) {
|
|
minDistX = p.dist
|
|
edgeNx = p.nx
|
|
guideEdgeX = p.guideX
|
|
}
|
|
}
|
|
|
|
// 에지 스냅 후보 계산 (Y축)
|
|
const selTop = rawTop
|
|
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 - (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) {
|
|
minDistY = p.dist
|
|
edgeNy = p.ny
|
|
guideEdgeY = p.guideY
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 우선순위: 에지 스냅 > 중심 스냅 > 그리드
|
|
if (typeof edgeNx === 'number') {
|
|
nx = edgeNx
|
|
} else if (typeof snappedCenterX === 'number') {
|
|
nx = snappedCenterX - (selectedIds.length > 1 ? groupWidth : selected.width) / 2
|
|
}
|
|
if (typeof edgeNy === 'number') {
|
|
ny = edgeNy
|
|
} else if (typeof snappedCenterY === 'number') {
|
|
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 })
|
|
|
|
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={() => {
|
|
// 드래그 종료 시 가이드 숨김
|
|
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>
|
|
)
|
|
}
|