"use client" import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' function snap8(v: number) { return Math.round(v / 8) * 8 } // (removed unused type guard) // 테스트 환경(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 onSelectIds?: (ids: string[]) => void enableGuides?: boolean } export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) { const [model, setModel] = useState(frame) // 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화 useEffect(() => { setModel(frame) }, [frame]) const initialId = model.layers[0]?.objects[0]?.id ?? null const [selectedIds, setSelectedIds] = useState(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 [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 } | null>(null) const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const rotateGroupRef = useRef<{ ids: string[]; orig: Record } | 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(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]) // 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 = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : '' return `${base}${pos}${guide}` }, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected]) 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() // 기준 객체(선택 anchor)에 대해 목표 좌표 계산 후 델타를 산출하여 멀티선택 전체에 동일 적용 const tx = selected.x + dx const ty = selected.y + dy // 스냅은 이동한 축에만 적용, 이동이 없는 축은 값 유지 const nx = dx !== 0 ? (useSnap ? snap8(tx) : tx) : selected.x const ny = dy !== 0 ? (useSnap ? snap8(ty) : ty) : selected.y const ddx = nx - selected.x const ddy = ny - selected.y if (selectedIds.length > 1) { updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy })) } else { updateObject(selected.id, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy })) } }, [selected, selectedIds, updateObject, updateObjects]) 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) const next = [...prev, o.id] try { onSelectIds?.(next) } catch {} return next }) } else { 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 } 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 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) try { onSelectIds?.(hits) } catch {} 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 } if (selectedIds.length > 1 && rotateGroupRef.current) { const { ids, orig } = rotateGroupRef.current const base = orig[rot.id] const dd = next - base.d updateObjects(ids, (o) => { const o0 = orig[o.id] if (!o0) return o return { ...o, rotate: o0.d + dd } }) } else { 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 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 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, 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 // 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 }) 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, 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 }, [marquee.active, showGuides]) const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => { e.stopPropagation() 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 = {} for (const ly of model.layers) { for (const ob of ly.objects) { if (ids.includes(ob.id)) orig[ob.id] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height } } } resizeGroupRef.current = { ids, orig } }, [selectedIds, model.layers]) const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => { e.stopPropagation() rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate } const ids = Array.from(new Set([...selectedIds, o.id])) const orig: Record = {} for (const ly of model.layers) { for (const ob of ly.objects) { if (ids.includes(ob.id)) orig[ob.id] = { d: ob.rotate } } } rotateGroupRef.current = { ids, orig } }, [selectedIds, model.layers]) return ( { // 빈 영역(객체/핸들이 아닌 영역) 클릭 시 마퀴 시작 또는 선택 해제 const t = e.target as HTMLElement const isObj = !!t.closest('[data-testid^="obj-"],[aria-label^="object "]') const isHandle = !!t.closest('[data-testid^="resize-handle-"]') if (!isObj && !isHandle) { setSelectedIds([]) setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }) try { onSelectIds?.([]) } catch {} } }} onMouseMove={onMouseMoveCanvas} onMouseUp={onMouseUpCanvas} style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }} data-testid="wysiwyg-canvas" > {enableGuides && (showGuides || isDragging) && ( <> > )} {ariaLiveText} {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: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none', border: '1px solid rgba(148,163,184,0.6)', background: 'rgba(15,23,42,0.8)', color: '#f9fafb', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 4, boxSizing: 'border-box' }} > {/* 실제 오브젝트 콘텐츠 렌더링 */} {o.type === 'text' && ( {o.props.text} )} {o.type === 'image' && ( )} {o.type === 'button' && ( {o.props.label} )} {o.type === 'input' && (() => { const cid = `${o.id}-control` const helpId = o.props.help ? `${cid}-help` : '' return ( {o.props.label} {o.props.help ? {o.props.help} : null} ) })()} {o.type === 'select' && (() => { const cid = `${o.id}-control` const helpId = o.props.help ? `${cid}-help` : '' return ( {o.props.label} { /* no-op in canvas preview */ }} > {(o.props.options ?? []).map((opt: string) => ( {opt} ))} {o.props.help ? {o.props.help} : null} ) })()} {o.type === 'textarea' && (() => { const cid = `${o.id}-control` const helpId = o.props.help ? `${cid}-help` : '' return ( {o.props.label} {o.props.help ? {o.props.help} : null} ) })()} {o.type === 'radio' && ( {o.props.legend} {(o.props.options ?? []).map((opt: string) => ( {}} required={!!o.props.required} /> {opt} ))} )} {o.type === 'checkbox' && ( {o.props.legend} {(o.props.options ?? []).map((opt: string) => ( {}} required={!!o.props.required} /> {opt} ))} )} {selectedIds.includes(o.id) && ( <> {/* West (left-middle) */} 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) */} 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) */} 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) */} 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) */} 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) */} 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 */} onMouseDownResize(e, o, 'se')} style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }} /> 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) */} onMouseDownResize(e, o, 'sw')} style={{ position: 'absolute', left: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }} /> > )} {selectedIds.includes(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 스냅 그리드 활성화 */} { 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 // 다중 선택 시: 그룹 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 } // 가이드 좌표 업데이트(에지 가이드 우선, 없으면 중심, 최후에는 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 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={() => { setIsDragging(false) setShowGuides(false) }} onRotate={({ rotate, inputEvent }) => { if (!selected) return 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 })) } }} /> ) }
{o.props.text}