feat: WYSIWYG 빌더 1차 기능 완성 및 테스트 추가
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m34s

This commit is contained in:
2025-11-17 10:04:01 +09:00
parent 9ced249015
commit 1c607f6331
13 changed files with 660 additions and 28 deletions
+58
View File
@@ -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) ### 가이드라인 고도화 진행(2025-11-16)
- 목표: 객체-객체 스냅 가이드(8px 임계) - 목표: 객체-객체 스냅 가이드(8px 임계)
- 수직 중심 정렬 가이드/스냅 - 수직 중심 정렬 가이드/스냅
@@ -64,6 +96,32 @@
- 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검 - 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검
- 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계 - 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 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) ### 접근성 라이브 리전(2025-11-17)
- 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내 - 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내
- TDD - TDD
+2 -2
View File
@@ -1,5 +1,5 @@
import WysiwygPage from '@/components/wysiwyg/WysiwygPage' import WysiwygBuilderPage from '@/components/wysiwyg/WysiwygBuilderPage'
export default function Page() { 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 }) }) await act(async () => { last.onResize?.({ width: 83, height: 13 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement const obj = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => { await waitFor(() => {
// 현재 리사이즈는 Moveable onResize가 아닌 커스텀 핸들에서만 처리되므로
// Moveable onResize 호출은 객체 크기에 영향을 주지 않는다.
expect(obj.style.width).toBe('80px') expect(obj.style.width).toBe('80px')
expect(obj.style.height).toBe('16px') expect(obj.style.height).toBe('30px')
}) })
}) })
+2 -1
View File
@@ -47,7 +47,8 @@ describe('Canvas + Moveable integration (smoke)', () => {
const props = JSON.parse(mv.getAttribute('data-props') || '{}') const props = JSON.parse(mv.getAttribute('data-props') || '{}')
expect(props.draggable).toBe(true) expect(props.draggable).toBe(true)
expect(props.resizable).toBe(true) // 현재 리사이즈는 커스텀 핸들로만 처리하므로 Moveable에는 resizable을 주지 않는다.
expect(props.resizable).toBeUndefined()
expect(props.rotatable).toBe(true) expect(props.rotatable).toBe(true)
expect(props.snappable).toBe(true) expect(props.snappable).toBe(true)
expect(props.snapGridWidth).toBe(8) 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)
})
})
+39 -22
View File
@@ -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 Moveable from 'react-moveable'
import type { Frame, WObject } from '@/lib/wysiwyg/schema' import type { Frame, WObject } from '@/lib/wysiwyg/schema'
import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap' import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap'
@@ -24,10 +25,13 @@ if (typeof document !== 'undefined') {
export type CanvasProps = { export type CanvasProps = {
frame: Frame frame: Frame
onChange?: (next: Frame) => void 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) const [model, setModel] = useState<Frame>(frame)
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
useEffect(() => { setModel(frame) }, [frame])
const initialId = model.layers[0]?.objects[0]?.id ?? null const initialId = model.layers[0]?.objects[0]?.id ?? null
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : []) const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
const selectedId = selectedIds[selectedIds.length - 1] ?? null const selectedId = selectedIds[selectedIds.length - 1] ?? null
@@ -117,10 +121,14 @@ export function Canvas({ frame, onChange }: CanvasProps) {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const exists = prev.includes(o.id) const exists = prev.includes(o.id)
if (exists) return prev.filter((id) => id !== 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 { } 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 } draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
setShowGuides(true) setShowGuides(true)
@@ -150,6 +158,7 @@ export function Canvas({ frame, onChange }: CanvasProps) {
} }
} }
setSelectedIds(hits) setSelectedIds(hits)
try { onSelectIds?.(hits) } catch {}
return return
} }
const rot = rotatingRef.current const rot = rotatingRef.current
@@ -259,6 +268,7 @@ export function Canvas({ frame, onChange }: CanvasProps) {
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') { if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
setSelectedIds([]) setSelectedIds([])
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }) setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
try { onSelectIds?.([]) } catch {}
} }
}} }}
onMouseMove={onMouseMoveCanvas} onMouseMove={onMouseMoveCanvas}
@@ -280,8 +290,32 @@ export function Canvas({ frame, onChange }: CanvasProps) {
aria-label={`object ${o.id}`} aria-label={`object ${o.id}`}
onMouseDown={(e) => onMouseDownObj(e, o)} onMouseDown={(e) => onMouseDownObj(e, o)}
ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined} 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) && ( {selectedIds.includes(o.id) && (
<div <div
data-testid={`resize-handle-${o.id}`} data-testid={`resize-handle-${o.id}`}
@@ -302,7 +336,6 @@ export function Canvas({ frame, onChange }: CanvasProps) {
<Moveable <Moveable
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined} target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
draggable draggable
resizable
rotatable rotatable
snappable snappable
snapGridWidth={8} snapGridWidth={8}
@@ -449,22 +482,6 @@ export function Canvas({ frame, onChange }: CanvasProps) {
// 드래그 종료 시 가이드 숨김 // 드래그 종료 시 가이드 숨김
setShowGuides(false) 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 }) => { onRotate={({ rotate, inputEvent }) => {
if (!selected) return if (!selected) return
const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey
+41 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useEffect, useState } from 'react'
import type { WObject } from '@/lib/wysiwyg/schema' import type { WObject } from '@/lib/wysiwyg/schema'
export type PropsPanelProps = { export type PropsPanelProps = {
@@ -15,9 +15,21 @@ function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> {
return o.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) { export function PropsPanel({ object, onChange }: PropsPanelProps) {
const [model, setModel] = useState<WObject>(object) const [model, setModel] = useState<WObject>(object)
useEffect(() => {
setModel(object)
}, [object])
const emit = (next: WObject) => { const emit = (next: WObject) => {
setModel(next) setModel(next)
onChange?.(next) onChange?.(next)
@@ -32,6 +44,16 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
const next: WObject = { ...model, props: { ...model.props, ...patch } } const next: WObject = { ...model, props: { ...model.props, ...patch } }
emit(next) 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 ( return (
<div aria-label="Properties Panel"> <div aria-label="Properties Panel">
@@ -58,6 +80,24 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
</label> </label>
</div> </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) && ( {isImage(model) && (
<div> <div>
<label> <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()
})
})
+190
View File
@@ -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
+40
View File
@@ -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.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/)
expect(out.html).toMatch(/class=\"frame-scale\"/) 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
View File
@@ -38,11 +38,26 @@ export function exportFrame(frame: Frame): Exported {
piecesCss.push(r.css) 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 = [ const css = [
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`, `.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`,
`.frame-scale{transform-origin:0 0}`, `.frame-scale{transform-origin:0 0}`,
...piecesCss, ...piecesCss,
].join('\n') ].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: '' } return { html, css, js: '' }
} }