163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
import React, { useCallback, 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
|
|
}
|
|
|
|
export type CanvasProps = {
|
|
frame: Frame
|
|
onChange?: (next: Frame) => void
|
|
}
|
|
|
|
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 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 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 (
|
|
<div
|
|
role="application"
|
|
aria-label="WYSIWYG Canvas"
|
|
tabIndex={0}
|
|
onKeyDown={onKeyDown}
|
|
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)}
|
|
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 && (
|
|
<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 && (
|
|
<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
|
|
draggable
|
|
resizable
|
|
rotatable
|
|
snappable
|
|
snapGridWidth={8}
|
|
snapGridHeight={8}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|