feat: WYSIWYG 빌더 1차 기능 완성 및 테스트 추가
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user