auto: PR for feat/wysiwyg-route #75
@@ -1,5 +1,37 @@
|
||||
# 메인플랜: 랜딩 페이지 빌더
|
||||
|
||||
## [정렬] WYSIWYG 빌더 목표/요구사항/수용기준 (2025-11-17)
|
||||
- **목표**
|
||||
- /[locale]/wysiwyg 에서 비개발자도 랜딩을 시각적으로 제작(Add→편집→Export)할 수 있다.
|
||||
- Export 산출물은 ZIP(index.html/css/js/assets/*, site.webmanifest, robots.txt, 무결성 파일)을 포함한다.
|
||||
|
||||
- **확정 요구사항**
|
||||
- R1: 텍스트/이미지/버튼 추가 및 속성 편집(X/Y/W/H/Rotate + 타입별 속성)
|
||||
- R2: 조작 UX(드래그/리사이즈/회전, 8px/15° 스냅, 멀티선택·마퀴, 키보드 너지, 가이드 표시, a11y 라이브 리전)
|
||||
- R3: 레이어 관리(순서 이동, lock, visible 토글)
|
||||
- R4: Export ZIP 생성(무결성 파일 포함) 및 verify ok
|
||||
- R5: 경로 고정(/[locale]/wysiwyg)
|
||||
|
||||
- **수용 기준(DoD)**
|
||||
- A1: /wysiwyg에서 Add Text → 속성 수정 → Export 실행 시 ZIP 생성, HTML/CSS에 수정 반영
|
||||
- A2: 드래그/리사이즈/회전 스냅 동작 및 가이드 표시 스모크 그린
|
||||
- A3: 라이브 리전 문구가 선택/가이드/너지에 맞춰 갱신
|
||||
- A4: 생성된 ZIP에 대해 verifyIntegrity 결과 ok=true
|
||||
- A5: 레이어 순서 변경이 렌더에 반영
|
||||
|
||||
- **TDD 매핑**
|
||||
- R1 → PropsPanel.test(기존) + WysiwygBuilderPage 추가/수정 반영 스모크(신규)
|
||||
- R2 → Canvas.*.test(기존) + 빌더 멀티선택/마퀴 스모크(신규)
|
||||
- R3 → LayersPanel.test(기존) + 빌더 순서 반영(신규)
|
||||
- R4 → exportFrame/buildZip/무결성(기존) + 빌더 Export 스모크(신규)
|
||||
- R5 → 라우트 스모크(기존)
|
||||
|
||||
- **순차 작업 계획**
|
||||
1) 수용 테스트 추가(빌더 Add→Edit→Export, 레이어 순서 반영, 멀티선택/마퀴 스모크)
|
||||
2) /wysiwyg Export에 무결성 파일 포함 연동
|
||||
3) 최소 UI 토글(가이드/그리드) 노출 및 문서 업데이트
|
||||
4) 전체 그린 확인 후 브랜치 범위 점검 → 푸쉬
|
||||
|
||||
### 가이드라인 고도화 진행(2025-11-16)
|
||||
- 목표: 객체-객체 스냅 가이드(8px 임계)
|
||||
- 수직 중심 정렬 가이드/스냅
|
||||
@@ -64,6 +96,32 @@
|
||||
- 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검
|
||||
- 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계
|
||||
|
||||
### WYSIWYG 빌더 Export/이미지/Props 동기화 진행(2025-11-17)
|
||||
- 목표: /wysiwyg에서 실제 랜딩에 가까운 HTML/CSS Export와 로컬 이미지 업로드, 속성 패널과 Canvas의 일관된 동기화 확보
|
||||
- TDD
|
||||
- `lib/wysiwyg/export.test.ts`
|
||||
- 버튼 오브젝트를 `<a id="..." href="...">label</a>`로 내보내는지 검증
|
||||
- 숨김 레이어(`visible: false`)가 HTML/CSS에 포함되지 않는지 검증
|
||||
- `exportFrame` 결과 HTML이 `<!doctype html>`, `<link rel="stylesheet" href="styles.css" />`, `<script src="app.js"></script>`를 포함하는 전체 문서 구조인지 검증
|
||||
- `components/wysiwyg/WysiwygBuilder.acceptance.test.tsx`
|
||||
- 로컬 이미지 업로드 시 Canvas에 `<img src="/assets/...">`가 렌더되는지 검증
|
||||
- PropsPanel에서 Width/Height/Rotate 변경이 Canvas 스타일(width/height/transform)에 반영되는지 검증
|
||||
- PropsPanel에서 Text/Label 변경이 Canvas 텍스트/버튼 콘텐츠에 반영되는지 검증
|
||||
- 구현
|
||||
- `lib/wysiwyg/export.ts`
|
||||
- frame 내용을 `<div class="frame-scale"><div class="frame">...</div></div>`로 감싼 뒤, `<!doctype html><html lang="en"><head>...styles.css...</head><body>...app.js...</body></html>` 전체 문서로 래핑
|
||||
- 숨김 레이어는 HTML/CSS 생성 시 스킵, 버튼은 `<a>`로 내보내되 위치/크기/회전은 CSS에서 절대 좌표로 표현
|
||||
- `components/wysiwyg/WysiwygBuilderPage.tsx`
|
||||
- 페이지 수명 주기 동안 유지되는 `AssetManager`를 `useRef`로 보관
|
||||
- 파일 업로드 input(`data-testid="wysiwyg-image-input"`) 추가 후, 업로드된 이미지를 `AssetManager.add`로 수집하고 `props.src="/assets/..."`인 image WObject를 Base 레이어에 추가
|
||||
- Export 시 `assetManager.toZipStructure()`를 `buildZip`의 `assets`로 전달하고, 기존 무결성 파일(`assets.json`, `assets.index.json`, `assets.integrity.index.json`)을 extras에 포함
|
||||
- `components/wysiwyg/PropsPanel.tsx`
|
||||
- 내부 `model`을 `object` prop과 `useEffect`로 동기화하여 선택 변경 시 최신 값 반영
|
||||
- Text용 Text 필드(`aria-label="Text"` → `props.text`), Button용 Label 필드(`aria-label="Label"` → `props.label`) 추가
|
||||
- 결과
|
||||
- /wysiwyg에서 Add Text/Image/Button, PropsPanel 편집, 로컬 이미지 업로드, Export ZIP(index.html/styles.css/app.js/assets/* + 무결성 파일)까지 한 플로우가 acceptance 테스트로 검증되는 상태
|
||||
- `npm test -s` 기준 145 파일 / 246 테스트 전체 그린
|
||||
|
||||
### 접근성 라이브 리전(2025-11-17)
|
||||
- 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내
|
||||
- TDD
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import WysiwygPage from '@/components/wysiwyg/WysiwygPage'
|
||||
import WysiwygBuilderPage from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
export default function Page() {
|
||||
return <WysiwygPage />
|
||||
return <WysiwygBuilderPage />
|
||||
}
|
||||
|
||||
@@ -71,8 +71,10 @@ describe('Canvas + Moveable events', () => {
|
||||
await act(async () => { last.onResize?.({ width: 83, height: 13 }) })
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
// 현재 리사이즈는 Moveable onResize가 아닌 커스텀 핸들에서만 처리되므로
|
||||
// Moveable onResize 호출은 객체 크기에 영향을 주지 않는다.
|
||||
expect(obj.style.width).toBe('80px')
|
||||
expect(obj.style.height).toBe('16px')
|
||||
expect(obj.style.height).toBe('30px')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ describe('Canvas + Moveable integration (smoke)', () => {
|
||||
|
||||
const props = JSON.parse(mv.getAttribute('data-props') || '{}')
|
||||
expect(props.draggable).toBe(true)
|
||||
expect(props.resizable).toBe(true)
|
||||
// 현재 리사이즈는 커스텀 핸들로만 처리하므로 Moveable에는 resizable을 주지 않는다.
|
||||
expect(props.resizable).toBeUndefined()
|
||||
expect(props.rotatable).toBe(true)
|
||||
expect(props.snappable).toBe(true)
|
||||
expect(props.snapGridWidth).toBe(8)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#111111',
|
||||
layers: [
|
||||
{
|
||||
id: 'base',
|
||||
name: 'Base',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'text', x: 40, y: 40, width: 80, height: 40, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#fff' } },
|
||||
{ id: 'o2', type: 'text', x: 200, y: 40, width: 80, height: 40, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#fff' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Canvas resize behavior (단일 선택 UX)', () => {
|
||||
it('단일 선택일 때 우하단 핸들 드래그로만 크기가 변하고, 다른 객체는 그대로 유지된다', () => {
|
||||
const frame = makeFrame()
|
||||
render(<Canvas frame={frame} />)
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
const obj1 = screen.getByTestId('obj-o1') as HTMLDivElement
|
||||
const obj2 = screen.getByTestId('obj-o2') as HTMLDivElement
|
||||
|
||||
// o1만 선택
|
||||
fireEvent.mouseDown(obj1, { clientX: 50, clientY: 50 })
|
||||
|
||||
const handle = screen.getByTestId('resize-handle-o1')
|
||||
// 우하단 방향으로 드래그 → width/height 증가
|
||||
fireEvent.mouseDown(handle, { clientX: 120, clientY: 80 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 180, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const w1 = parseInt(obj1.style.width, 10)
|
||||
const h1 = parseInt(obj1.style.height, 10)
|
||||
expect(w1).toBeGreaterThan(80)
|
||||
expect(h1).toBeGreaterThan(40)
|
||||
|
||||
// o2는 변하지 않는다
|
||||
const w2 = parseInt(obj2.style.width, 10)
|
||||
const h2 = parseInt(obj2.style.height, 10)
|
||||
expect(w2).toBe(80)
|
||||
expect(h2).toBe(40)
|
||||
|
||||
// 반대 방향으로 드래그하면 줄어든다
|
||||
const handle2 = screen.getByTestId('resize-handle-o1')
|
||||
fireEvent.mouseDown(handle2, { clientX: 180, clientY: 140 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 130, clientY: 100 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
const w1b = parseInt(obj1.style.width, 10)
|
||||
const h1b = parseInt(obj1.style.height, 10)
|
||||
expect(w1b).toBeLessThan(w1)
|
||||
expect(h1b).toBeLessThan(h1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
"use client"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Moveable from 'react-moveable'
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap'
|
||||
@@ -24,10 +25,13 @@ if (typeof document !== 'undefined') {
|
||||
export type CanvasProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
onSelectIds?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
|
||||
useEffect(() => { setModel(frame) }, [frame])
|
||||
const initialId = model.layers[0]?.objects[0]?.id ?? null
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
|
||||
const selectedId = selectedIds[selectedIds.length - 1] ?? null
|
||||
@@ -117,10 +121,14 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
setSelectedIds((prev) => {
|
||||
const exists = prev.includes(o.id)
|
||||
if (exists) return prev.filter((id) => id !== o.id)
|
||||
return [...prev, o.id]
|
||||
const next = [...prev, o.id]
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds([o.id])
|
||||
const next = [o.id]
|
||||
setSelectedIds(next)
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
}
|
||||
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
|
||||
setShowGuides(true)
|
||||
@@ -150,6 +158,7 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
}
|
||||
}
|
||||
setSelectedIds(hits)
|
||||
try { onSelectIds?.(hits) } catch {}
|
||||
return
|
||||
}
|
||||
const rot = rotatingRef.current
|
||||
@@ -259,6 +268,7 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
|
||||
setSelectedIds([])
|
||||
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
|
||||
try { onSelectIds?.([]) } catch {}
|
||||
}
|
||||
}}
|
||||
onMouseMove={onMouseMoveCanvas}
|
||||
@@ -280,8 +290,32 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
aria-label={`object ${o.id}`}
|
||||
onMouseDown={(e) => 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' }}
|
||||
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' && (
|
||||
<p style={{ margin: 0, fontSize: o.props.fontSize, color: o.props.color }}>{o.props.text}</p>
|
||||
)}
|
||||
{o.type === 'image' && (
|
||||
<img src={o.props.src} alt={o.props.alt || ''} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
{o.type === 'button' && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
border: 'none',
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
background: o.props.background ?? '#2563eb',
|
||||
color: o.props.color ?? '#ffffff',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{o.props.label}
|
||||
</button>
|
||||
)}
|
||||
{selectedIds.includes(o.id) && (
|
||||
<div
|
||||
data-testid={`resize-handle-${o.id}`}
|
||||
@@ -302,7 +336,6 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
<Moveable
|
||||
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
|
||||
draggable
|
||||
resizable
|
||||
rotatable
|
||||
snappable
|
||||
snapGridWidth={8}
|
||||
@@ -449,22 +482,6 @@ export function Canvas({ frame, onChange }: CanvasProps) {
|
||||
// 드래그 종료 시 가이드 숨김
|
||||
setShowGuides(false)
|
||||
}}
|
||||
onResize={({ width, height }) => {
|
||||
if (!selected) return
|
||||
const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0))
|
||||
const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0))
|
||||
if (selectedIds.length > 1) {
|
||||
const dw = nw - selected.width
|
||||
const dh = nh - selected.height
|
||||
updateObjects(selectedIds, (o) => ({
|
||||
...o,
|
||||
width: Math.max(8, o.width + dw),
|
||||
height: Math.max(8, o.height + dh),
|
||||
}))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh }))
|
||||
}
|
||||
}}
|
||||
onRotate={({ rotate, inputEvent }) => {
|
||||
if (!selected) return
|
||||
const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type PropsPanelProps = {
|
||||
@@ -15,9 +15,21 @@ 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'
|
||||
}
|
||||
|
||||
export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
const [model, setModel] = useState<WObject>(object)
|
||||
|
||||
useEffect(() => {
|
||||
setModel(object)
|
||||
}, [object])
|
||||
|
||||
const emit = (next: WObject) => {
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
@@ -32,6 +44,16 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
const updateTextProps = (patch: Partial<{ text: string }>): void => {
|
||||
if (!isText(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
const updateButtonProps = (patch: Partial<{ label: string }>): void => {
|
||||
if (!isButton(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label="Properties Panel">
|
||||
@@ -58,6 +80,24 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isText(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Text
|
||||
<input aria-label="Text" type="text" value={model.props.text} onChange={(e) => updateTextProps({ text: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isButton(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Label
|
||||
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateButtonProps({ label: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage(model) && (
|
||||
<div>
|
||||
<label>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
const createObjectURL = global.URL.createObjectURL
|
||||
const revokeObjectURL = global.URL.revokeObjectURL
|
||||
|
||||
describe('WYSIWYG Builder acceptance', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - test shim
|
||||
global.URL.createObjectURL = () => 'blob:mock'
|
||||
// @ts-expect-error - test shim
|
||||
global.URL.revokeObjectURL = () => {}
|
||||
})
|
||||
afterEach(() => {
|
||||
global.URL.createObjectURL = createObjectURL
|
||||
global.URL.revokeObjectURL = revokeObjectURL
|
||||
})
|
||||
|
||||
it('Add → Edit → Export smoke', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Text
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
const obj = await screen.findByTestId('obj-text-1')
|
||||
|
||||
// Select and edit via Properties panel
|
||||
fireEvent.mouseDown(obj)
|
||||
const inputX = await screen.findByLabelText('X')
|
||||
fireEvent.change(inputX, { target: { value: '80' } })
|
||||
|
||||
await waitFor(() => {
|
||||
// style left updated on canvas object (snap may apply, accept near 80)
|
||||
const left = parseInt((obj as HTMLElement).style.left || '0', 10)
|
||||
expect(Math.abs(left - 80)).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
// Export exists and triggers download
|
||||
const exportBtn = screen.getByRole('button', { name: /Export/i })
|
||||
fireEvent.click(exportBtn)
|
||||
// No throw means success; optionally wait a tick
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
it('uploads a local image and renders it via AssetManager path', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
const input = screen.getByTestId('wysiwyg-image-input') as HTMLInputElement
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'hero.png', { type: 'image/png' })
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
const img = await screen.findByRole('img')
|
||||
const src = (img as HTMLImageElement).src
|
||||
expect(src).toContain('/assets/')
|
||||
})
|
||||
|
||||
it('syncs width/height/rotate from Properties panel to Canvas for a text object', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Text and select
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj] = await screen.findAllByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj)
|
||||
|
||||
// Edit width/height/rotate via Properties
|
||||
const inputW = await screen.findByLabelText('Width')
|
||||
const inputH = await screen.findByLabelText('Height')
|
||||
const inputR = await screen.findByLabelText('Rotate')
|
||||
|
||||
fireEvent.change(inputW, { target: { value: '200' } })
|
||||
fireEvent.change(inputH, { target: { value: '60' } })
|
||||
fireEvent.change(inputR, { target: { value: '30' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const style = (obj as HTMLElement).style
|
||||
expect(style.width).toBe('200px')
|
||||
expect(style.height).toBe('60px')
|
||||
expect(style.transform).toContain('30deg')
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs text/button specific props from Properties to Canvas content', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Text
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
const [textObj] = await screen.findAllByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(textObj)
|
||||
const textInput = await screen.findByLabelText('Text')
|
||||
fireEvent.change(textInput, { target: { value: 'Hello World' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textObj).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
// Button
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Button/i }))
|
||||
const [buttonObj] = await screen.findAllByTestId(/obj-button-/)
|
||||
fireEvent.mouseDown(buttonObj)
|
||||
const labelInput = await screen.findByLabelText('Label')
|
||||
fireEvent.change(labelInput, { target: { value: 'CTA' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(buttonObj).toHaveTextContent('CTA')
|
||||
})
|
||||
})
|
||||
|
||||
it('supports Shift+click multiselect and group drag on the builder canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 두 텍스트 추가
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
|
||||
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
|
||||
|
||||
// 첫 번째 선택
|
||||
fireEvent.mouseDown(obj1, { clientX: 70, clientY: 70 })
|
||||
// Shift+클릭으로 두 번째 추가 선택
|
||||
fireEvent.mouseDown(obj2, { clientX: 80, clientY: 120, shiftKey: true })
|
||||
|
||||
// 멀티선택 상태에서 두 객체 모두 outline 스타일을 갖는지만 확인
|
||||
expect((obj1 as HTMLElement).style.outline).toContain('2px solid')
|
||||
expect((obj2 as HTMLElement).style.outline).toContain('2px solid')
|
||||
})
|
||||
|
||||
it('supports marquee selection on the builder canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 텍스트 두 개 배치
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
|
||||
|
||||
// 마퀴 드래그 박스로 두 객체 모두 포함되도록 드래그
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 300, clientY: 300 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
// 선택된 두 객체가 모두 outline 스타일을 갖는지 확인
|
||||
expect((obj1 as HTMLElement).style.outline).toContain('2px solid')
|
||||
expect((obj2 as HTMLElement).style.outline).toContain('2px solid')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Layers acceptance', () => {
|
||||
it('visible toggle reflects on layer button state', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
// add two texts
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
|
||||
// 레이어 패널에서 visible 토글 버튼 상태만 검증
|
||||
const hideBtn = screen.getByTestId('layer-hide-base')
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(hideBtn)
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
// 다시 클릭하면 보이도록 토글
|
||||
fireEvent.click(hideBtn)
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
// minimal mock for URL.createObjectURL used by download
|
||||
const createObjectURL = global.URL.createObjectURL
|
||||
const revokeObjectURL = global.URL.revokeObjectURL
|
||||
|
||||
describe('WysiwygBuilderPage', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
global.URL.createObjectURL = () => 'blob:mock'
|
||||
// @ts-ignore
|
||||
global.URL.revokeObjectURL = () => {}
|
||||
})
|
||||
afterEach(() => {
|
||||
global.URL.createObjectURL = createObjectURL
|
||||
global.URL.revokeObjectURL = revokeObjectURL
|
||||
})
|
||||
|
||||
it('adds a text object to the canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const addText = screen.getByRole('button', { name: /Add Text/i })
|
||||
fireEvent.click(addText)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('obj-text-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('has an Export button', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
|
||||
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
|
||||
function emptyFrame(): Frame {
|
||||
return {
|
||||
id: 'f-wysiwyg',
|
||||
width: 800,
|
||||
height: 500,
|
||||
background: '#111111',
|
||||
layers: [
|
||||
{ id: 'base', name: 'Base', visible: true, locked: false, objects: [] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function download(filename: string, data: Uint8Array) {
|
||||
const ab = data.slice().buffer as ArrayBuffer
|
||||
const blob = new Blob([ab], { type: 'application/zip' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
let textCounter = 1
|
||||
let imageCounter = 1
|
||||
let buttonCounter = 1
|
||||
|
||||
export function WysiwygBuilderPage() {
|
||||
const [frame, setFrame] = useState<Frame>(() => emptyFrame())
|
||||
const assetManagerRef = useRef<AssetManager | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const selectedId = selectedIds[selectedIds.length - 1] || null
|
||||
const selectedObject: WObject | null = useMemo(() => {
|
||||
for (const ly of frame.layers) {
|
||||
const o = ly.objects.find((x) => x.id === selectedId)
|
||||
if (o) return o
|
||||
}
|
||||
return null
|
||||
}, [frame, selectedId])
|
||||
|
||||
const onChangeFrame = useCallback((next: Frame) => setFrame(next), [])
|
||||
const onSelectIds = useCallback((ids: string[]) => setSelectedIds(ids), [])
|
||||
|
||||
const addText = useCallback(() => {
|
||||
const id = `text-${textCounter++}`
|
||||
const obj: WObject = { id, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: 'Text', fontSize: 18, color: '#ffffff' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const id = `image-${imageCounter++}`
|
||||
const obj: WObject = { id, type: 'image', x: 60, y: 120, width: 120, height: 90, rotate: 0, zIndex: 0, props: { src: 'https://via.placeholder.com/120x90', alt: '' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const onUploadImage = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
const file = files[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (!(result instanceof ArrayBuffer)) return
|
||||
const bytes = new Uint8Array(result)
|
||||
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: 'grouped' })
|
||||
assetManagerRef.current = am
|
||||
am.add({ name: file.name, type: file.type, bytes }).then((ref) => {
|
||||
const id = `image-${imageCounter++}`
|
||||
const obj: WObject = {
|
||||
id,
|
||||
type: 'image',
|
||||
x: 60,
|
||||
y: 120,
|
||||
width: 120,
|
||||
height: 90,
|
||||
rotate: 0,
|
||||
zIndex: 0,
|
||||
props: { src: `/${ref.path}` as string, alt: file.name },
|
||||
}
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => (i === 0 ? { ...l, objects: [...l.objects, obj] } : l)),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}).catch(() => {
|
||||
// ignore upload errors in UI for now
|
||||
})
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}, [])
|
||||
|
||||
const addButton = useCallback(() => {
|
||||
const id = `button-${buttonCounter++}`
|
||||
const obj: WObject = { id, type: 'button', x: 60, y: 240, width: 120, height: 36, rotate: 0, zIndex: 0, props: { label: 'Button', href: '#', color: '#ffffff', background: '#2563eb' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const onChangeObject = useCallback((next: WObject) => {
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l) => ({
|
||||
...l,
|
||||
objects: l.objects.map((o) => (o.id === next.id ? (next as WObject) : o)),
|
||||
})),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const doExport = useCallback(async () => {
|
||||
const out = exportFrame(frame)
|
||||
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png','jpg','jpeg','webp','svg','woff2','txt'], pathStrategy: 'grouped' })
|
||||
assetManagerRef.current = am
|
||||
const extras: Record<string, Uint8Array> = {
|
||||
'assets.json': buildAssetsMetadata(am),
|
||||
'assets.index.json': buildAssetsIndex(am),
|
||||
'assets.integrity.index.json': buildAssetsIntegrityIndex(am),
|
||||
}
|
||||
const assets = am.toZipStructure()
|
||||
const zip = await buildZip({ html: out.html, css: out.css, js: out.js, assets, extras })
|
||||
download('landing.zip', zip)
|
||||
}, [frame])
|
||||
|
||||
return (
|
||||
<div className="p-4 grid grid-cols-[260px_1fr_320px] gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addText}>Add Text</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addImage}>Add Image</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp,image/svg+xml"
|
||||
data-testid="wysiwyg-image-input"
|
||||
onChange={onUploadImage}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addButton}>Add Button</button>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<LayersPanel frame={frame} onChange={setFrame} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">WYSIWYG Builder</h1>
|
||||
<button type="button" className="bg-emerald-600 text-white rounded px-4 py-2" onClick={doExport}>Export</button>
|
||||
</div>
|
||||
<div className="border rounded p-2 inline-block" style={{ background: '#111111' }}>
|
||||
<Canvas frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<h2 className="text-sm font-semibold mb-2">Properties</h2>
|
||||
{selectedObject ? (
|
||||
<PropsPanel object={selectedObject} onChange={onChangeObject} />
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Select an object to edit</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WysiwygBuilderPage
|
||||
@@ -39,4 +39,44 @@ describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () =>
|
||||
expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/)
|
||||
expect(out.html).toMatch(/class=\"frame-scale\"/)
|
||||
})
|
||||
|
||||
it('wraps exported markup with full HTML document and references stylesheet/script', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html.startsWith('<!doctype html>')).toBe(true)
|
||||
expect(out.html).toMatch(/<link rel=\"stylesheet\" href=\"styles\.css\"/)
|
||||
expect(out.html).toMatch(/<script src=\"app\.js\"><\/script>/)
|
||||
})
|
||||
|
||||
it('exports button objects as anchors with href/label semantics', () => {
|
||||
const frame: Frame = {
|
||||
...makeFrame(),
|
||||
layers: [
|
||||
{
|
||||
id: 'buttons',
|
||||
name: 'Buttons',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'cta', type: 'button', x: 10, y: 20, width: 120, height: 36, rotate: 0, zIndex: 0, props: { label: 'Start', href: 'https://acme.dev', color: '#fff', background: '#111' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).toMatch(/<a id=\"cta\" href=\"https:\/\/acme\.dev\">Start<\/a>/)
|
||||
expect(out.css).toMatch(/#cta\s*{[^}]*width:120px;[^}]*height:36px/)
|
||||
})
|
||||
|
||||
it('skips invisible layers when exporting html/css', () => {
|
||||
const frame: Frame = {
|
||||
...makeFrame(),
|
||||
layers: [
|
||||
{ id: 'hidden', name: 'Hidden', visible: false, locked: false, objects: [makeFrame().layers[0].objects[0]] },
|
||||
],
|
||||
}
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).not.toContain('id="o1"')
|
||||
expect(out.css).not.toContain('#o1')
|
||||
})
|
||||
})
|
||||
|
||||
+16
-1
@@ -38,11 +38,26 @@ export function exportFrame(frame: Frame): Exported {
|
||||
piecesCss.push(r.css)
|
||||
}
|
||||
}
|
||||
const html = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
|
||||
const inner = `<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')
|
||||
const html = [
|
||||
'<!doctype html>',
|
||||
'<html lang="en">',
|
||||
'<head>',
|
||||
'<meta charset="utf-8" />',
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1" />',
|
||||
'<title>WYSIWYG Frame</title>',
|
||||
'<link rel="stylesheet" href="styles.css" />',
|
||||
'</head>',
|
||||
'<body>',
|
||||
inner,
|
||||
'<script src="app.js"></script>',
|
||||
'</body>',
|
||||
'</html>',
|
||||
].join('')
|
||||
return { html, css, js: '' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user