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) } // 테스트 환경(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 } export function Canvas({ frame, onChange }: CanvasProps) { const [model, setModel] = useState(frame) const [selectedId, setSelectedId] = useState(model.layers[0]?.objects[0]?.id ?? 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 }) // 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환) const selectedElRef = useRef(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 onKeyDown = useCallback((e: React.KeyboardEvent) => { if (!selected) return let dx = 0, dy = 0 if (e.key === 'ArrowLeft') dx = -1 else if (e.key === 'ArrowRight') dx = 1 else if (e.key === 'ArrowUp') dy = -1 else if (e.key === 'ArrowDown') dy = 1 else return e.preventDefault() updateObject(selected.id, (o) => { const nx = snap8(o.x + dx) const ny = snap8(o.y + dy) return { ...o, x: nx, y: ny } }) }, [selected, updateObject]) const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => { setSelectedId(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) => { 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]) const onMouseUpCanvas = useCallback(() => { draggingRef.current = null resizingRef.current = null rotatingRef.current = null setShowGuides(false) }, []) 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 (
{showGuides && ( <>
)} {model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
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 && (
onMouseDownResize(e, o)} style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }} /> )} {selectedId === o.id && (
onMouseDownRotate(e, o)} style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }} /> )}
)))} {/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */} { // 드래그 시작 시 가이드 표시 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 })) }} />
) }