feat(wysiwyg): 캔버스 키보드 nudge/마우스 드래그 + 8px 스냅 (M1 1차)
- Frame/Layer/Object 스키마(zod) 및 테스트 - 키보드 화살표 nudge 시 8px 스냅 적용 - 마우스 드래그 이동 시 8px 스냅 적용 - Canvas 컴포넌트 초기 버전 및 테스트 추가
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
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 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 }
|
||||
}, [])
|
||||
|
||||
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
|
||||
const d = draggingRef.current
|
||||
if (!d) return
|
||||
const dx = e.clientX - d.startX
|
||||
const dy = e.clientY - d.startY
|
||||
const nx = snap8(d.origX + dx)
|
||||
const ny = snap8(d.origY + dy)
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}, [updateObject])
|
||||
|
||||
const onMouseUpCanvas = useCallback(() => {
|
||||
draggingRef.current = null
|
||||
}, [])
|
||||
|
||||
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"
|
||||
>
|
||||
{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' }}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user