feat(wysiwyg): 캔버스 키보드 nudge/마우스 드래그 + 8px 스냅 (M1 1차)
- Frame/Layer/Object 스키마(zod) 및 테스트 - 키보드 화살표 nudge 시 8px 스냅 적용 - 마우스 드래그 이동 시 8px 스냅 적용 - Canvas 컴포넌트 초기 버전 및 테스트 추가
This commit is contained in:
@@ -423,3 +423,37 @@
|
|||||||
### 커밋/푸쉬 원칙
|
### 커밋/푸쉬 원칙
|
||||||
- 기능 단위 커밋은 한국어 메시지
|
- 기능 단위 커밋은 한국어 메시지
|
||||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||||
|
|
||||||
|
## WYSIWYG 캔버스(드림위버 스타일) 계획
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
- 컨텐츠 영역에서 오브젝트를 자유 배치(드래그/리사이즈/회전)하여 랜딩을 구성
|
||||||
|
- 웹표준/접근성/모바일·PC 반응형을 모두 충족하는 Export 산출
|
||||||
|
|
||||||
|
### 핵심 설계 원칙
|
||||||
|
- 의미론적 HTML Export: Text→h1~h6/p, Button→a/button, Image→img/picture
|
||||||
|
- 접근성
|
||||||
|
- 레이어(탭) 순서 ↔ DOM 순서 매핑, 필요 시 탭 순서 수동 설정
|
||||||
|
- 이미지 alt/버튼 aria-label 필수, 헤딩 레벨 강제, 키보드 포커스/스킵링크 유지
|
||||||
|
- 색 대비/타겟 크기 가이드 및 경고
|
||||||
|
- 반응형
|
||||||
|
- 1차: 고정 비율 스케일(MVP)
|
||||||
|
- 2차: 브레이크포인트 재배치(선택 그룹)
|
||||||
|
- 3차: 유동 단위/컨테이너 쿼리, clamp 폰트 확장
|
||||||
|
- 품질 가드: 8px 격자 + 가장자리/중심 스냅, 오버랩/가독성 경고, 역할/속성 프리셋
|
||||||
|
|
||||||
|
### 기술/구현 전략
|
||||||
|
- 모델: Frame/Layer/Object(Text/Image/Button) + 의미/접근성 필드(zod 스키마)
|
||||||
|
- 편집: Moveable로 드래그/리사이즈/회전 + 스냅/가이드/키보드 nudge
|
||||||
|
- Export: 오브젝트→의미론 태그 매핑, 반응형(스케일→브레이크포인트), 이미지 최적화 옵션 반영
|
||||||
|
|
||||||
|
### 마일스톤
|
||||||
|
- M1: 캔버스 조작(드/리/회+스냅) TDD → 최소 구현
|
||||||
|
- M2: 레이어/속성 패널 TDD → UI 연결(순서/잠금/숨김, 위치/크기/회전/폰트/색/링크/이미지)
|
||||||
|
- M3: Export(의미론+스케일) TDD → ZIP 생성 연동
|
||||||
|
- M4: a11y/반응형 회귀 스모크 확대 + 품질 가드/경고
|
||||||
|
|
||||||
|
### 진행 현황
|
||||||
|
- 브랜치: feat/wysiwyg-canvas 생성
|
||||||
|
- 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test)
|
||||||
|
- 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function makeFrame(): Frame {
|
||||||
|
return {
|
||||||
|
id: 'f1',
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'l1',
|
||||||
|
name: 'L1',
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
objects: [
|
||||||
|
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WYSIWYG Canvas mouse drag with 8px snap', () => {
|
||||||
|
it('dragging object snaps to nearest 8px', () => {
|
||||||
|
const frame = makeFrame()
|
||||||
|
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||||
|
const canvas = getByTestId('wysiwyg-canvas')
|
||||||
|
const obj = getByTestId('obj-o1')
|
||||||
|
|
||||||
|
// start drag
|
||||||
|
fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 })
|
||||||
|
// move by +5,+5 (should snap from 101->104, 100->104)
|
||||||
|
fireEvent.mouseMove(canvas, { clientX: 205, clientY: 155 })
|
||||||
|
fireEvent.mouseUp(canvas)
|
||||||
|
|
||||||
|
expect(obj).toHaveStyle({ left: '104px', top: '104px' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function makeFrame(): Frame {
|
||||||
|
return {
|
||||||
|
id: 'f1',
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'l1',
|
||||||
|
name: 'L1',
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
objects: [
|
||||||
|
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => {
|
||||||
|
it('ArrowRight nudges and snaps x to nearest 8px grid', () => {
|
||||||
|
const frame = makeFrame()
|
||||||
|
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||||
|
const canvas = getByTestId('wysiwyg-canvas')
|
||||||
|
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
|
||||||
|
const obj = getByTestId('obj-o1')
|
||||||
|
expect(obj).toHaveStyle({ left: '104px' }) // 101 + 1 => 102 -> snap8 => 104
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ArrowUp nudges and snaps y to nearest 8px grid', () => {
|
||||||
|
const frame = makeFrame()
|
||||||
|
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||||
|
const canvas = getByTestId('wysiwyg-canvas')
|
||||||
|
fireEvent.keyDown(canvas, { key: 'ArrowUp' })
|
||||||
|
const obj = getByTestId('obj-o1')
|
||||||
|
expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { frameSchema, objectSchema, layerSchema, type Frame, type WObject } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function validText(id: string): WObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'text',
|
||||||
|
x: 100,
|
||||||
|
y: 120,
|
||||||
|
width: 300,
|
||||||
|
height: 40,
|
||||||
|
rotate: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
props: { text: 'Hello', fontSize: 18, color: '#111111' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validImage(id: string): WObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
x: 50,
|
||||||
|
y: 60,
|
||||||
|
width: 240,
|
||||||
|
height: 180,
|
||||||
|
rotate: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
props: { src: 'https://example.com/a.jpg', alt: 'A' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wysiwyg.schema', () => {
|
||||||
|
it('validates a Frame with layers and objects', () => {
|
||||||
|
const frame: Frame = {
|
||||||
|
id: 'frame-1',
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{ id: 'layer-1', name: 'Base', visible: true, locked: false, objects: [validImage('img-1')] },
|
||||||
|
{ id: 'layer-2', name: 'Text', visible: true, locked: false, objects: [validText('txt-1')] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const parsed = frameSchema.parse(frame)
|
||||||
|
expect(parsed.layers.length).toBe(2)
|
||||||
|
expect(parsed.layers[1].objects[0].type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid color and negative sizes', () => {
|
||||||
|
const bad: Frame = {
|
||||||
|
id: 'f', width: -10, height: 0, background: 'blue', layers: [],
|
||||||
|
} as unknown as Frame
|
||||||
|
expect(() => frameSchema.parse(bad)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('objectSchema enforces type-specific props', () => {
|
||||||
|
const ok = objectSchema.parse(validText('t1'))
|
||||||
|
expect(ok.props.text).toBe('Hello')
|
||||||
|
|
||||||
|
const bad = { ...validText('t2'), props: { src: 'x' } }
|
||||||
|
expect(() => objectSchema.parse(bad as unknown as WObject)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('layerSchema enforces ordering integers and object bounds', () => {
|
||||||
|
const good = layerSchema.parse({ id: 'l1', name: 'L', visible: true, locked: false, objects: [validImage('i1')] })
|
||||||
|
expect(good.objects[0].zIndex).toBe(0)
|
||||||
|
|
||||||
|
const badObj = { ...validImage('i2'), width: -1 }
|
||||||
|
expect(() => objectSchema.parse(badObj as unknown as WObject)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const colorHex = z.string().regex(/^#([0-9a-fA-F]{3}){1,2}$/)
|
||||||
|
|
||||||
|
const baseObject = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
rotate: z.number(),
|
||||||
|
zIndex: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const textObject = baseObject.extend({
|
||||||
|
type: z.literal('text'),
|
||||||
|
props: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
fontSize: z.number().positive(),
|
||||||
|
color: colorHex,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageObject = baseObject.extend({
|
||||||
|
type: z.literal('image'),
|
||||||
|
props: z.object({
|
||||||
|
src: z.string().min(1),
|
||||||
|
alt: z.string().optional().default(''),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonObject = baseObject.extend({
|
||||||
|
type: z.literal('button'),
|
||||||
|
props: z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
href: z.string().optional().default(''),
|
||||||
|
color: colorHex.optional(),
|
||||||
|
background: colorHex.optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject])
|
||||||
|
export type WObject = z.infer<typeof objectSchema>
|
||||||
|
|
||||||
|
export const layerSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
visible: z.boolean(),
|
||||||
|
locked: z.boolean(),
|
||||||
|
objects: z.array(objectSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const frameSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
background: colorHex,
|
||||||
|
layers: z.array(layerSchema),
|
||||||
|
})
|
||||||
|
export type Frame = z.infer<typeof frameSchema>
|
||||||
Reference in New Issue
Block a user