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
@@ -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')
})
})
+2 -1
View File
@@ -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)
})
})
+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 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
+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'
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()
})
})
+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