Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d33fc0d0 | |||
| 1737ae172e |
@@ -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,42 @@
|
||||
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: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas resize with 8px snap', () => {
|
||||
it('dragging bottom-right handle resizes and snaps to nearest 8px', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('resize-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start resize
|
||||
fireEvent.mouseDown(handle, { clientX: 150, clientY: 140 })
|
||||
// move by +7,+9 → width 50-> 57 -> snap8 => 56, height 40->49 -> snap8 => 48
|
||||
fireEvent.mouseMove(canvas, { clientX: 157, clientY: 149 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ width: '56px', height: '48px' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
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: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas rotate with snap', () => {
|
||||
it('rotates with 15deg snap by default', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('rotate-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start rotate at 0deg
|
||||
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
|
||||
// move +16px on x → +16deg → snap15 => 15deg
|
||||
fireEvent.mouseMove(canvas, { clientX: 116, clientY: 100 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ transform: 'rotate(15deg)' })
|
||||
})
|
||||
|
||||
it('rotates with 1deg step while holding Shift', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('rotate-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
|
||||
// move +7px with Shift → +7deg, no snap
|
||||
fireEvent.mouseMove(canvas, { clientX: 107, clientY: 100, shiftKey: true })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ transform: 'rotate(7deg)' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
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 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 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 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)
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}, [updateObject])
|
||||
|
||||
const onMouseUpCanvas = useCallback(() => {
|
||||
draggingRef.current = null
|
||||
resizingRef.current = null
|
||||
rotatingRef.current = null
|
||||
}, [])
|
||||
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1', width: 400, height: 300, background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] },
|
||||
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('LayersPanel', () => {
|
||||
it('reorders layers with up/down buttons', () => {
|
||||
const frame = makeFrame()
|
||||
const onChange = vi.fn()
|
||||
const { getByTestId, getAllByTestId } = render(<LayersPanel frame={frame} onChange={onChange} />)
|
||||
const items = getAllByTestId('layer-item')
|
||||
expect(items[0]).toHaveTextContent('Base')
|
||||
expect(items[1]).toHaveTextContent('Text')
|
||||
|
||||
fireEvent.click(getByTestId('layer-down-l1'))
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
const next = (onChange.mock.lastCall?.[0]) as Frame
|
||||
expect(next.layers[0].id).toBe('l2')
|
||||
expect(next.layers[1].id).toBe('l1')
|
||||
})
|
||||
|
||||
it('toggles lock and visibility', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<LayersPanel frame={frame} onChange={() => {}} />)
|
||||
fireEvent.click(getByTestId('layer-lock-l2'))
|
||||
fireEvent.click(getByTestId('layer-hide-l2'))
|
||||
// no crash; states toggled reflected in aria-pressed
|
||||
expect(getByTestId('layer-lock-l2')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(getByTestId('layer-hide-l2')).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type LayersPanelProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
}
|
||||
|
||||
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
|
||||
const move = useCallback((id: string, dir: 1 | -1) => {
|
||||
const idx = model.layers.findIndex((l) => l.id === id)
|
||||
if (idx < 0) return
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= model.layers.length) return
|
||||
const nextLayers = model.layers.slice()
|
||||
const [it] = nextLayers.splice(idx, 1)
|
||||
nextLayers.splice(j, 0, it)
|
||||
const next = { ...model, layers: nextLayers }
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}, [model, onChange])
|
||||
|
||||
const toggle = useCallback((id: string, key: 'locked' | 'visible') => {
|
||||
const nextLayers = model.layers.map((l) => l.id === id ? { ...l, [key]: key === 'visible' ? !l.visible : !l.locked } : l)
|
||||
const next = { ...model, layers: nextLayers }
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}, [model, onChange])
|
||||
|
||||
return (
|
||||
<div aria-label="Layers Panel">
|
||||
{model.layers.map((l) => (
|
||||
<div key={l.id} data-testid="layer-item" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span>{l.name}</span>
|
||||
<button type="button" data-testid={`layer-up-${l.id}`} onClick={() => move(l.id, -1)} aria-label={`move up ${l.name}`}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" data-testid={`layer-down-${l.id}`} onClick={() => move(l.id, 1)} aria-label={`move down ${l.name}`}>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`layer-lock-${l.id}`}
|
||||
aria-pressed={l.locked ? 'true' : 'false'}
|
||||
onClick={() => toggle(l.id, 'locked')}
|
||||
aria-label={`lock ${l.name}`}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`layer-hide-${l.id}`}
|
||||
aria-pressed={l.visible ? 'false' : 'true'}
|
||||
onClick={() => toggle(l.id, 'visible')}
|
||||
aria-label={`toggle ${l.name} visibility`}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
|
||||
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
describe('PropsPanel', () => {
|
||||
it('edits position/size/rotate numeric fields', () => {
|
||||
const obj: WObject = { id: 'o1', type: 'image', x: 10, y: 20, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } }
|
||||
const calls: WObject[] = []
|
||||
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
|
||||
fireEvent.change(getByLabelText('X'), { target: { value: '12' } })
|
||||
fireEvent.change(getByLabelText('Y'), { target: { value: '30' } })
|
||||
fireEvent.change(getByLabelText('Width'), { target: { value: '120' } })
|
||||
fireEvent.change(getByLabelText('Height'), { target: { value: '88' } })
|
||||
fireEvent.change(getByLabelText('Rotate'), { target: { value: '15' } })
|
||||
const last = calls.at(-1)!
|
||||
expect(last.x).toBe(12)
|
||||
expect(last.y).toBe(30)
|
||||
expect(last.width).toBe(120)
|
||||
expect(last.height).toBe(88)
|
||||
expect(last.rotate).toBe(15)
|
||||
})
|
||||
|
||||
it('edits image src/alt', () => {
|
||||
const obj: WObject = { id: 'o2', type: 'image', x: 0, y: 0, width: 10, height: 10, rotate: 0, zIndex: 0, props: { src: 'x', alt: 'a' } }
|
||||
const calls: WObject[] = []
|
||||
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
|
||||
fireEvent.change(getByLabelText('Src'), { target: { value: 'https://example.com/img.jpg' } })
|
||||
fireEvent.change(getByLabelText('Alt'), { target: { value: 'Example' } })
|
||||
expect(calls.at(-1)?.props).toEqual({ src: 'https://example.com/img.jpg', alt: 'Example' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from 'react'
|
||||
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type PropsPanelProps = {
|
||||
object: WObject
|
||||
onChange?: (next: WObject) => void
|
||||
}
|
||||
|
||||
function num(v: string, fallback: number) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> {
|
||||
return o.type === 'image'
|
||||
}
|
||||
|
||||
export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
const [model, setModel] = useState<WObject>(object)
|
||||
|
||||
const emit = (next: WObject) => {
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const update = (patch: Partial<Pick<WObject, 'x' | 'y' | 'width' | 'height' | 'rotate'>>): void => {
|
||||
const next: WObject = { ...model, ...patch }
|
||||
emit(next)
|
||||
}
|
||||
const updateImageProps = (patch: Partial<{ src: string; alt?: string }>): void => {
|
||||
if (!isImage(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label="Properties Panel">
|
||||
<div>
|
||||
<label>
|
||||
X
|
||||
<input aria-label="X" type="number" value={model.x} onChange={(e) => update({ x: num(e.target.value, model.x) })} />
|
||||
</label>
|
||||
<label>
|
||||
Y
|
||||
<input aria-label="Y" type="number" value={model.y} onChange={(e) => update({ y: num(e.target.value, model.y) })} />
|
||||
</label>
|
||||
<label>
|
||||
Width
|
||||
<input aria-label="Width" type="number" value={model.width} onChange={(e) => update({ width: num(e.target.value, model.width) })} />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input aria-label="Height" type="number" value={model.height} onChange={(e) => update({ height: num(e.target.value, model.height) })} />
|
||||
</label>
|
||||
<label>
|
||||
Rotate
|
||||
<input aria-label="Rotate" type="number" value={model.rotate} onChange={(e) => update({ rotate: num(e.target.value, model.rotate) })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isImage(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Src
|
||||
<input aria-label="Src" type="text" value={model.props.src} onChange={(e) => updateImageProps({ src: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Alt
|
||||
<input aria-label="Alt" type="text" value={model.props.alt || ''} onChange={(e) => updateImageProps({ alt: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1', width: 320, height: 200, background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } },
|
||||
]},
|
||||
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [
|
||||
{ id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } },
|
||||
]},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => {
|
||||
it('renders wrapper with fixed width/height and absolutely positioned children', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).toMatch(/<div class=\"frame\"/)
|
||||
expect(out.css).toMatch(/\.frame\s*{[^}]*position:relative/)
|
||||
expect(out.css).toMatch(/width:320px/)
|
||||
expect(out.css).toMatch(/height:200px/)
|
||||
// image object
|
||||
expect(out.html).toMatch(/<img[^>]*src=\"https:\/\/example.com\/a.jpg\"/)
|
||||
expect(out.css).toMatch(/#o1\s*{[^}]*left:16px;[^}]*top:24px;[^}]*width:80px;[^}]*height:60px;[^}]*transform:rotate\(15deg\)/)
|
||||
// text object
|
||||
expect(out.html).toMatch(/<p id=\"t1\"/)
|
||||
expect(out.html).toMatch(/Hello/)
|
||||
expect(out.css).toMatch(/#t1\s*{[^}]*left:24px;[^}]*top:120px;[^}]*width:200px;[^}]*height:32px/)
|
||||
})
|
||||
|
||||
it('includes scale wrapper token for responsive fit', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/)
|
||||
expect(out.html).toMatch(/class=\"frame-scale\"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type Exported = { html: string; css: string; js: string }
|
||||
|
||||
function esc(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> { return o.type === 'image' }
|
||||
function isText(o: WObject): o is Extract<WObject, { type: 'text' }> { return o.type === 'text' }
|
||||
function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> { return o.type === 'button' }
|
||||
|
||||
function renderObject(o: WObject): { html: string; css: string } {
|
||||
const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}`
|
||||
if (isImage(o)) {
|
||||
const html = `<img id="${o.id}" src="${esc(o.props.src)}" alt="${esc(o.props.alt || '')}" />`
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
if (isText(o)) {
|
||||
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
|
||||
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
|
||||
}
|
||||
// button minimal
|
||||
const label = isButton(o) ? o.props.label : 'Button'
|
||||
const href = isButton(o) ? (o.props.href || '#') : '#'
|
||||
const html = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
|
||||
export function exportFrame(frame: Frame): Exported {
|
||||
const piecesHtml: string[] = []
|
||||
const piecesCss: string[] = []
|
||||
for (const layer of frame.layers) {
|
||||
if (!layer.visible) continue
|
||||
for (const o of layer.objects) {
|
||||
const r = renderObject(o)
|
||||
piecesHtml.push(r.html)
|
||||
piecesCss.push(r.css)
|
||||
}
|
||||
}
|
||||
const html = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
|
||||
const css = [
|
||||
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`,
|
||||
`.frame-scale{transform-origin:0 0}`,
|
||||
...piecesCss,
|
||||
].join('\n')
|
||||
return { html, css, js: '' }
|
||||
}
|
||||
@@ -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