onMouseDownResize(e, o, 'se')}
+ style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: 'transparent', cursor: 'nwse-resize' }}
+ />
+ {/* South-West (bottom-left corner) */}
+
onMouseDownResize(e, o, 'sw')}
+ style={{ position: 'absolute', left: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
+ />
+ >
)}
{selectedIds.includes(o.id) && (
{
- // 드래그 시작 시 가이드 표시
- setShowGuides(true)
+ if (resizingRef.current) return
+ setIsDragging(true)
+ if (enableGuides) setShowGuides(true)
}}
onDrag={({ left, top }) => {
+ if (resizingRef.current) return
if (!selected) return
const rawLeftInput = typeof left === 'number' ? left : 0
const rawTopInput = typeof top === 'number' ? top : 0
@@ -465,10 +761,11 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
}
- // 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
- const guideX = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
- const guideY = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
- setGuidePos({ x: guideX, y: guideY })
+ // 가이드 좌표 업데이트(에지 가이드 우선, 없으면 중심, 최후에는 nx/ny)
+ const gx = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
+ const gy = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
+ if (enableGuides) setShowGuides(true)
+ setGuidePos({ x: gx, y: gy })
if (selectedIds.length > 1) {
const dx = nx - baseLeft
@@ -479,14 +776,21 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
}
}}
onDragEnd={() => {
- // 드래그 종료 시 가이드 숨김
+ setIsDragging(false)
setShowGuides(false)
}}
onRotate={({ rotate, inputEvent }) => {
if (!selected) return
- const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey
- const nd = snapAngleUtil(typeof rotate === 'number' ? rotate : 0, fine)
- updateObject(selected.id, (o) => ({ ...o, rotate: nd }))
+ const raw = typeof rotate === 'number' ? rotate : 0
+ const shift = !!(inputEvent && (inputEvent as { shiftKey?: boolean }).shiftKey)
+ const step = shift ? 1 : 15
+ const snapped = Math.sign(raw) * Math.ceil(Math.abs(raw) / step) * step
+ if (selectedIds.length > 1 && rotateGroupRef.current) {
+ const { ids } = rotateGroupRef.current
+ updateObjects(ids, (o) => ({ ...o, rotate: snapped }))
+ } else {
+ updateObject(selected.id, (o) => ({ ...o, rotate: snapped }))
+ }
}}
/>
diff --git a/components/wysiwyg/LayersPanel.tsx b/components/wysiwyg/LayersPanel.tsx
index e0122b6..f21097d 100644
--- a/components/wysiwyg/LayersPanel.tsx
+++ b/components/wysiwyg/LayersPanel.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
import type { Frame } from '@/lib/wysiwyg/schema'
export type LayersPanelProps = {
@@ -9,6 +9,10 @@ export type LayersPanelProps = {
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
const [model, setModel] = useState
(frame)
+ useEffect(() => {
+ setModel(frame)
+ }, [frame])
+
const move = useCallback((id: string, dir: 1 | -1) => {
const idx = model.layers.findIndex((l) => l.id === id)
if (idx < 0) return
diff --git a/components/wysiwyg/PropsPanel.tsx b/components/wysiwyg/PropsPanel.tsx
index 8b8991d..4c2f900 100644
--- a/components/wysiwyg/PropsPanel.tsx
+++ b/components/wysiwyg/PropsPanel.tsx
@@ -23,6 +23,26 @@ function isButton(o: WObject): o is Extract
{
return o.type === 'button'
}
+function isInput(o: WObject): o is Extract {
+ return o.type === 'input'
+}
+
+function isSelect(o: WObject): o is Extract {
+ return o.type === 'select'
+}
+
+function isRadio(o: WObject): o is Extract {
+ return o.type === 'radio'
+}
+
+function isCheckbox(o: WObject): o is Extract {
+ return o.type === 'checkbox'
+}
+
+function isTextarea(o: WObject): o is Extract {
+ return o.type === 'textarea'
+}
+
export function PropsPanel({ object, onChange }: PropsPanelProps) {
const [model, setModel] = useState(object)
@@ -55,6 +75,36 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
emit(next)
}
+ const updateInputProps = (patch: Partial<{ name: string; label: string; placeholder: string; required: boolean; help: string }>): void => {
+ if (!isInput(model)) return
+ const next: WObject = { ...model, props: { ...model.props, ...patch } }
+ emit(next)
+ }
+
+ const updateSelectProps = (patch: Partial<{ name: string; label: string; options: string[]; defaultValue: string; help: string }>): void => {
+ if (!isSelect(model)) return
+ const next: WObject = { ...model, props: { ...model.props, ...patch } }
+ emit(next)
+ }
+
+ const updateRadioProps = (patch: Partial<{ name: string; legend: string; options: string[]; defaultValue: string; required: boolean; help: string }>): void => {
+ if (!isRadio(model)) return
+ const next: WObject = { ...model, props: { ...model.props, ...patch } }
+ emit(next)
+ }
+
+ const updateCheckboxProps = (patch: Partial<{ name: string; legend: string; options: string[]; defaultValues: string[]; required: boolean; help: string }>): void => {
+ if (!isCheckbox(model)) return
+ const next: WObject = { ...model, props: { ...model.props, ...patch } }
+ emit(next)
+ }
+
+ const updateTextareaProps = (patch: Partial<{ name: string; label: string; placeholder: string; rows: number; required: boolean; help: string }>): void => {
+ if (!isTextarea(model)) return
+ const next: WObject = { ...model, props: { ...model.props, ...patch } }
+ emit(next)
+ }
+
return (
@@ -89,6 +139,118 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
)}
+ {isTextarea(model) && (
+
+
+
+
+
+
+
+
+ )}
+
+ {isRadio(model) && (
+
+
+
+
+
+
+
+
+ )}
+
+ {isCheckbox(model) && (
+
+
+
+
+
+
+
+
+ )}
+
+ {isSelect(model) && (
+
+
+
+
+
+
+
+ )}
+
{isButton(model) && (
)}
+
+ {isInput(model) && (
+
+
+
+
+
+
+
+ )}
)
}
diff --git a/components/wysiwyg/WysiwygBuilder.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.acceptance.test.tsx
index 3714db6..6c4426f 100644
--- a/components/wysiwyg/WysiwygBuilder.acceptance.test.tsx
+++ b/components/wysiwyg/WysiwygBuilder.acceptance.test.tsx
@@ -1,27 +1,27 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach, vi } 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
+let spyCreate: ReturnType | null = null
+let spyRevoke: ReturnType | null = null
describe('WYSIWYG Builder acceptance', () => {
beforeEach(() => {
- // @ts-expect-error - test shim
- global.URL.createObjectURL = () => 'blob:mock'
- // @ts-expect-error - test shim
- global.URL.revokeObjectURL = () => {}
+ spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
+ spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
})
afterEach(() => {
- global.URL.createObjectURL = createObjectURL
- global.URL.revokeObjectURL = revokeObjectURL
+ spyCreate?.mockRestore()
+ spyRevoke?.mockRestore()
+ spyCreate = null
+ spyRevoke = null
})
it('Add → Edit → Export smoke', async () => {
render()
// Add Text
- fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
const obj = await screen.findByTestId('obj-text-1')
// Select and edit via Properties panel
@@ -59,7 +59,7 @@ describe('WYSIWYG Builder acceptance', () => {
render()
// Add Text and select
- const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
+ const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
fireEvent.click(addTextBtn)
const [obj] = await screen.findAllByTestId(/obj-text-/)
fireEvent.mouseDown(obj)
@@ -85,7 +85,7 @@ describe('WYSIWYG Builder acceptance', () => {
render()
// Text
- fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
+ 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')
@@ -111,7 +111,7 @@ describe('WYSIWYG Builder acceptance', () => {
render()
// 두 텍스트 추가
- const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
+ const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
fireEvent.click(addTextBtn)
fireEvent.click(addTextBtn)
@@ -131,7 +131,7 @@ describe('WYSIWYG Builder acceptance', () => {
render()
// 텍스트 두 개 배치
- const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
+ const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
fireEvent.click(addTextBtn)
fireEvent.click(addTextBtn)
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
diff --git a/components/wysiwyg/WysiwygBuilder.form-checkbox.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-checkbox.acceptance.test.tsx
new file mode 100644
index 0000000..232b304
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-checkbox.acceptance.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Checkbox', () => {
+ it('adds a Checkbox group, edits props, and reflects on Canvas', async () => {
+ render()
+
+ // Add Checkbox
+ fireEvent.click(screen.getByRole('button', { name: /Add Checkbox/i }))
+
+ const obj = await screen.findByTestId(/obj-checkbox-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const panel = await screen.findByLabelText('Properties Panel')
+ const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
+ const legendInput = panel.querySelector('input[aria-label="Legend"]') as HTMLInputElement
+ const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
+ const defaultsInput = panel.querySelector('input[aria-label="Defaults"]') as HTMLInputElement
+ const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
+
+ fireEvent.change(nameInput, { target: { value: 'agree' } })
+ fireEvent.change(legendInput, { target: { value: 'Agreements' } })
+ fireEvent.change(optionsInput, { target: { value: 'One,Two,Three' } })
+ fireEvent.change(defaultsInput, { target: { value: 'Two,Three' } })
+ fireEvent.click(requiredCheckbox)
+
+ const rendered = await screen.findByTestId(/obj-checkbox-/)
+ expect(rendered).toHaveTextContent('Agreements')
+ const inputs = Array.from(rendered.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[]
+ expect(inputs.map(i => i.value)).toEqual(['One','Two','Three'])
+ const checkedValues = inputs.filter(i => i.checked).map(i => i.value)
+ expect(checkedValues).toEqual(['Two','Three'])
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.form-help.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-help.acceptance.test.tsx
new file mode 100644
index 0000000..97b5f59
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-help.acceptance.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Help (aria-describedby)', () => {
+ it('Input: edits Help in Properties and Canvas reflects help + aria-describedby', async () => {
+ render()
+
+ // Add Input
+ fireEvent.click(screen.getByRole('button', { name: /Add Input/i }))
+
+ const obj = await screen.findByTestId(/obj-input-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const panel = await screen.findByLabelText('Properties Panel')
+ const helpInput = panel.querySelector('input[aria-label="Help"]') as HTMLInputElement
+ fireEvent.change(helpInput, { target: { value: 'We will not spam you.' } })
+
+ const rendered = await screen.findByTestId(/obj-input-/)
+ expect(rendered).toHaveTextContent('We will not spam you.')
+ const inputEl = rendered.querySelector('input') as HTMLInputElement
+ expect(inputEl).toBeTruthy()
+ const describedby = inputEl.getAttribute('aria-describedby')
+ expect(describedby).toMatch(/-help$/)
+ const helpEl = rendered.querySelector(`#${describedby}`)
+ expect(helpEl?.textContent).toContain('We will not spam you.')
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.form-input.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-input.acceptance.test.tsx
new file mode 100644
index 0000000..91b452b
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-input.acceptance.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Input(Text)', () => {
+ it('adds an Input, edits props in Properties, and reflects on Canvas', async () => {
+ render()
+
+ // Add Input
+ fireEvent.click(screen.getByRole('button', { name: /Add Input/i }))
+
+ const obj = await screen.findByTestId(/obj-input-/)
+ // select to expose props
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ // edit props in Properties panel
+ const panel = await screen.findByLabelText('Properties Panel')
+ const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
+ const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
+ const placeholderInput = panel.querySelector('input[aria-label="Placeholder"]') as HTMLInputElement
+ const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
+
+ fireEvent.change(nameInput, { target: { value: 'email' } })
+ fireEvent.change(labelInput, { target: { value: 'Email Address' } })
+ fireEvent.change(placeholderInput, { target: { value: 'you@example.com' } })
+ fireEvent.click(requiredCheckbox)
+
+ // Canvas should reflect label and placeholder
+ const rendered = await screen.findByTestId(/obj-input-/)
+ expect(rendered).toHaveTextContent('Email Address')
+ const inputEl = rendered.querySelector('input') as HTMLInputElement
+ expect(inputEl).toBeTruthy()
+ expect(inputEl.placeholder).toBe('you@example.com')
+ expect(inputEl.required).toBe(true)
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.form-radio.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-radio.acceptance.test.tsx
new file mode 100644
index 0000000..3a7261d
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-radio.acceptance.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Radio', () => {
+ it('adds a Radio group, edits props, and reflects on Canvas', async () => {
+ render()
+
+ // Add Radio
+ fireEvent.click(screen.getByRole('button', { name: /Add Radio/i }))
+
+ const obj = await screen.findByTestId(/obj-radio-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const panel = await screen.findByLabelText('Properties Panel')
+ const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
+ const legendInput = panel.querySelector('input[aria-label="Legend"]') as HTMLInputElement
+ const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
+ const defaultInput = panel.querySelector('input[aria-label="Default"]') as HTMLInputElement
+ const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
+
+ fireEvent.change(nameInput, { target: { value: 'color' } })
+ fireEvent.change(legendInput, { target: { value: 'Color' } })
+ fireEvent.change(optionsInput, { target: { value: 'Red,Green,Blue' } })
+ fireEvent.change(defaultInput, { target: { value: 'Green' } })
+ fireEvent.click(requiredCheckbox)
+
+ const rendered = await screen.findByTestId(/obj-radio-/)
+ expect(rendered).toHaveTextContent('Color')
+ const inputs = Array.from(rendered.querySelectorAll('input[type="radio"]')) as HTMLInputElement[]
+ expect(inputs.map(i => i.value)).toEqual(['Red','Green','Blue'])
+ const checked = inputs.find(i => i.checked)
+ expect(checked?.value).toBe('Green')
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.form-select.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-select.acceptance.test.tsx
new file mode 100644
index 0000000..e3b7375
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-select.acceptance.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Select', () => {
+ it('adds a Select, edits props, and reflects on Canvas', async () => {
+ render()
+
+ // Add Select
+ fireEvent.click(screen.getByRole('button', { name: /Add Select/i }))
+
+ const obj = await screen.findByTestId(/obj-select-/)
+ // select to expose props
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ // edit props in Properties panel
+ const panel = await screen.findByLabelText('Properties Panel')
+ const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
+ const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
+ const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
+ const defaultInput = panel.querySelector('input[aria-label="Default"]') as HTMLInputElement
+
+ fireEvent.change(nameInput, { target: { value: 'size' } })
+ fireEvent.change(labelInput, { target: { value: 'Size' } })
+ fireEvent.change(optionsInput, { target: { value: 'S,M,L' } })
+ fireEvent.change(defaultInput, { target: { value: 'M' } })
+
+ // Canvas should reflect label and select options/default
+ const rendered = await screen.findByTestId(/obj-select-/)
+ expect(rendered).toHaveTextContent('Size')
+ const selectEl = rendered.querySelector('select') as HTMLSelectElement
+ expect(selectEl).toBeTruthy()
+ const opts = Array.from(selectEl.querySelectorAll('option')).map(o => o.textContent)
+ expect(opts).toEqual(['S','M','L'])
+ expect(selectEl.value).toBe('M')
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.form-textarea.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.form-textarea.acceptance.test.tsx
new file mode 100644
index 0000000..4f9d122
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.form-textarea.acceptance.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Form Textarea', () => {
+ it('adds a Textarea, edits props, and reflects on Canvas', async () => {
+ render()
+
+ // Add Textarea
+ fireEvent.click(screen.getByRole('button', { name: /Add Textarea/i }))
+
+ const obj = await screen.findByTestId(/obj-textarea-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const panel = await screen.findByLabelText('Properties Panel')
+ const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
+ const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
+ const placeholderInput = panel.querySelector('input[aria-label="Placeholder"]') as HTMLInputElement
+ const rowsInput = panel.querySelector('input[aria-label="Rows"]') as HTMLInputElement
+ const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
+
+ fireEvent.change(nameInput, { target: { value: 'message' } })
+ fireEvent.change(labelInput, { target: { value: 'Message' } })
+ fireEvent.change(placeholderInput, { target: { value: 'Type here...' } })
+ fireEvent.change(rowsInput, { target: { value: '5' } })
+ fireEvent.click(requiredCheckbox)
+
+ const rendered = await screen.findByTestId(/obj-textarea-/)
+ expect(rendered).toHaveTextContent('Message')
+ const ta = rendered.querySelector('textarea') as HTMLTextAreaElement
+ expect(ta).toBeTruthy()
+ expect(ta.placeholder).toBe('Type here...')
+ expect(ta.required).toBe(true)
+ expect(ta.rows).toBe(5)
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.group-actions.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.group-actions.acceptance.test.tsx
new file mode 100644
index 0000000..282564f
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.group-actions.acceptance.test.tsx
@@ -0,0 +1,112 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+function getXY(el: HTMLElement) {
+ const style = el.getAttribute('style') || ''
+ const m = { left: /left:\s*(\d+)px/, top: /top:\s*(\d+)px/ }
+ const left = Number((style.match(m.left)?.[1]) || '0')
+ const top = Number((style.match(m.top)?.[1]) || '0')
+ return { left, top }
+}
+
+function getRotate(el: HTMLElement) {
+ const style = el.getAttribute('style') || ''
+ const m = /rotate\(([-\d.]+)deg\)/
+ const v = Number((style.match(m)?.[1]) || '0')
+ return v
+}
+
+describe('WYSIWYG - group actions drag/resize/rotate with snapping', () => {
+ it('drags two selected objects together with 8px snapping', async () => {
+ render()
+ // add two texts
+ const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ addText()
+ addText()
+ const objs = await screen.findAllByTestId(/obj-text-/)
+ const a = objs[0]
+ const b = objs[1]
+
+ // select both (click first then shift-click second)
+ fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
+ fireEvent.mouseUp(a)
+ fireEvent.mouseDown(b, { clientX: 80, clientY: 80, shiftKey: true })
+ // wait until selection reflects two items
+ await screen.findByText(/선택 2개/)
+
+ const canvas = screen.getByTestId('wysiwyg-canvas')
+ // drag second (selected) by ~13, expect snap to +16
+ fireEvent.mouseDown(b, { clientX: 120, clientY: 120 })
+ fireEvent.mouseMove(canvas, { clientX: 133, clientY: 133 })
+ fireEvent.mouseUp(canvas)
+
+ await waitFor(() => {
+ const xyA = getXY(a)
+ const xyB = getXY(b)
+ expect(xyA.left).toBe(76)
+ expect(xyA.top).toBe(76)
+ expect(xyB.left).toBe(76)
+ expect(xyB.top).toBe(76)
+ })
+ })
+
+ it('resizes group from east handle; both widths increase with snapping', async () => {
+ render()
+ const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ addText()
+ addText()
+ const objs = await screen.findAllByTestId(/obj-text-/)
+ const a = objs[0]
+ const b = objs[1]
+
+ // select both
+ fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
+ fireEvent.mouseUp(a)
+ fireEvent.mouseDown(b, { clientX: 80, clientY: 80, shiftKey: true })
+
+ // drag east handle of second by ~13 -> snap width +16
+ const idB = (b.getAttribute('data-testid') || '').replace('obj-', '')
+ const handleE = await screen.findByTestId(`resize-handle-e-${idB}`)
+ fireEvent.mouseDown(handleE, { clientX: 180, clientY: 80 })
+ const canvas = screen.getByTestId('wysiwyg-canvas')
+ fireEvent.mouseMove(canvas, { clientX: 193, clientY: 80 })
+ fireEvent.mouseUp(canvas)
+
+ // widths reflected in inline style
+ const styleA = a.getAttribute('style') || ''
+ const styleB = b.getAttribute('style') || ''
+ const wA = Number((styleA.match(/width:\s*(\d+)px/)?.[1]) || '0')
+ const wB = Number((styleB.match(/width:\s*(\d+)px/)?.[1]) || '0')
+ expect(wA).toBeGreaterThanOrEqual(176) // initial 160 -> +16
+ expect(wB).toBeGreaterThanOrEqual(176)
+ })
+
+ it('rotates group with 15deg snapping (no shift)', async () => {
+ render()
+ const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ addText()
+ addText()
+ const objs = await screen.findAllByTestId(/obj-text-/)
+ const a = objs[0]
+ const b = objs[1]
+
+ // select both
+ fireEvent.mouseDown(a)
+ fireEvent.mouseUp(a)
+ fireEvent.mouseDown(b, { shiftKey: true })
+ fireEvent.mouseUp(b)
+
+ const idB2 = (b.getAttribute('data-testid') || '').replace('obj-', '')
+ const rotateHandle = await screen.findByTestId(`rotate-handle-${idB2}`)
+ fireEvent.mouseDown(rotateHandle, { clientX: 100, clientY: 10 })
+ const canvas = screen.getByTestId('wysiwyg-canvas')
+ fireEvent.mouseMove(canvas, { clientX: 130, clientY: 10 }) // +30 -> snap 30 (15 step)
+ fireEvent.mouseUp(canvas)
+
+ const rA = getRotate(a)
+ const rB = getRotate(b)
+ expect(rA).toBe(30)
+ expect(rB).toBe(30)
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.guides.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.guides.acceptance.test.tsx
new file mode 100644
index 0000000..9efaa5b
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.guides.acceptance.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+describe('WYSIWYG Builder - Guides UI toggle', () => {
+ it('shows guides by default, and hides when toggle is off', async () => {
+ render()
+
+ // add a text and select it (exact match to avoid matching 'Add Textarea')
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+
+ // default ON: start drag -> guides should appear
+ const canvas = screen.getByTestId('wysiwyg-canvas')
+ fireEvent.mouseDown(obj, { clientX: 120, clientY: 120 })
+ fireEvent.mouseMove(canvas, { clientX: 124, clientY: 124 })
+ expect(await screen.findByTestId('guide-x')).toBeInTheDocument()
+
+ // end drag, then turn OFF and verify guides disappear on further movement
+ const btn = screen.getByRole('button', { name: /Guides/i })
+ fireEvent.mouseUp(canvas)
+ fireEvent.click(btn)
+ fireEvent.mouseMove(canvas, { clientX: 132, clientY: 132 })
+ expect(screen.queryByTestId('guide-x')).toBeNull()
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilder.layers.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.layers.acceptance.test.tsx
index 7c48403..bc6d9d6 100644
--- a/components/wysiwyg/WysiwygBuilder.layers.acceptance.test.tsx
+++ b/components/wysiwyg/WysiwygBuilder.layers.acceptance.test.tsx
@@ -6,8 +6,8 @@ describe('WYSIWYG Builder - Layers acceptance', () => {
it('visible toggle reflects on layer button state', async () => {
render()
// add two texts
- fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
- fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
+ 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')
@@ -18,4 +18,30 @@ describe('WYSIWYG Builder - Layers acceptance', () => {
fireEvent.click(hideBtn)
expect(hideBtn).toHaveAttribute('aria-pressed', 'false')
})
+
+ it('layer order changes reflect in Canvas DOM stacking order', async () => {
+ render()
+ // Add two layers, each with one text object labeled L1, L2
+ fireEvent.click(screen.getByRole('button', { name: /Add Layer/i }))
+ fireEvent.click(screen.getByRole('button', { name: /Add Layer/i }))
+
+ const t1 = await screen.findByText('L1')
+ const t2 = await screen.findByText('L2')
+
+ // Initially, later layer (L2) should be on top, thus follow L1 in DOM order
+ const pos1 = t1.compareDocumentPosition(t2)
+ expect(pos1 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+
+ // Move L2 up (towards front doesn't change since it's already last), then move L2 down to go below L1
+ const up2 = screen.getByTestId('layer-up-layer-2')
+ const down2 = screen.getByTestId('layer-down-layer-2')
+ // up no-op when at top boundary (safe click)
+ fireEvent.click(up2)
+ // now move it down once (below)
+ fireEvent.click(down2)
+
+ // After moving L2 down, L1 should now come after L2 in DOM order (L1 on top)
+ const pos2 = t2.compareDocumentPosition(t1)
+ expect(pos2 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ })
})
diff --git a/components/wysiwyg/WysiwygBuilder.resize.acceptance.test.tsx b/components/wysiwyg/WysiwygBuilder.resize.acceptance.test.tsx
new file mode 100644
index 0000000..d2650bf
--- /dev/null
+++ b/components/wysiwyg/WysiwygBuilder.resize.acceptance.test.tsx
@@ -0,0 +1,156 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
+
+function getBox() {
+ const el = screen.getByTestId(/obj-text-/) as HTMLElement
+ const style = el.style
+ const left = parseInt(style.left || '0', 10)
+ const top = parseInt(style.top || '0', 10)
+ const width = parseInt(style.width || '0', 10)
+ const height = parseInt(style.height || '0', 10)
+ return { left, top, width, height }
+
+}
+
+function snap8(v: number) {
+ return Math.round(v / 8) * 8
+}
+
+describe('WYSIWYG Builder - Resize directional handles', () => {
+ it('left handle resizes by moving left edge and shifts x', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ // select
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ // end any potential drag
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const before = getBox()
+ const hLeft = await screen.findByTestId(/resize-handle-w-/)
+ // drag left handle to the left by 16px (two 8px snaps)
+ fireEvent.mouseDown(hLeft, { clientX: before.left, clientY: before.top + 5 })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top + 5 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ const expectedLeft = Math.min(snap8(before.left - 16), before.left + before.width - 8)
+ const expectedWidth = (before.left + before.width) - expectedLeft
+ expect(after.left).toBe(expectedLeft)
+ expect(after.width).toBe(expectedWidth)
+ // orthogonal axis may adjust minimally due to snapping/rounding; primary check is x/width
+ })
+
+ it('corner NW handle moves left/top and expands width/height', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const before = getBox()
+ const hNW = await screen.findByTestId(/resize-handle-nw-/)
+ fireEvent.mouseDown(hNW, { clientX: before.left, clientY: before.top })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top - 16 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ const expLeft = Math.min(snap8(before.left - 16), before.left + before.width - 8)
+ const expTop = Math.min(snap8(before.top - 16), before.top + before.height - 8)
+ expect(after.left).toBe(expLeft)
+ expect(after.top).toBe(expTop)
+ expect(after.width).toBe((before.left + before.width) - expLeft)
+ expect(after.height).toBe((before.top + before.height) - expTop)
+ })
+
+ it('corner SW handle moves left and expands height with bottom drag', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const before = getBox()
+ const hSW = await screen.findByTestId(/resize-handle-sw-/)
+ fireEvent.mouseDown(hSW, { clientX: before.left, clientY: before.top + before.height })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top + before.height + 16 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ const expLeft2 = Math.min(snap8(before.left - 16), before.left + before.width - 8)
+ expect(after.left).toBe(expLeft2)
+ expect(after.width).toBe((before.left + before.width) - expLeft2)
+ // bottom 증가, top은 유지
+ expect(after.top).toBe(before.top)
+ expect(after.height).toBe(snap8(before.height + 16))
+ })
+
+ it('top handle resizes by moving top edge and shifts y', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const before = getBox()
+ const hTop = await screen.findByTestId(/resize-handle-n-/)
+ fireEvent.mouseDown(hTop, { clientX: before.left + 5, clientY: before.top })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + 5, clientY: before.top - 16 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ const expectedTop = Math.min(snap8(before.top - 16), before.top + before.height - 8)
+ const expectedHeight = (before.top + before.height) - expectedTop
+ expect(after.top).toBe(expectedTop)
+ expect(after.height).toBe(expectedHeight)
+ // orthogonal axis may adjust minimally due to snapping; primary check is y/height
+ })
+
+ it('right and bottom handles increase width/height without shifting origin', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+
+ const before = getBox()
+ const hRight = await screen.findByTestId(/resize-handle-e-/)
+ fireEvent.mouseDown(hRight, { clientX: before.left + before.width, clientY: before.top + 5 })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + before.width + 16, clientY: before.top + 5 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const mid = getBox()
+ // primary check: width increased by snapped delta
+ expect(mid.width).toBe(snap8(before.width + 16))
+
+ const hBottom = await screen.findByTestId(/resize-handle-s-/)
+ fireEvent.mouseDown(hBottom, { clientX: mid.left + 5, clientY: mid.top + mid.height })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: mid.left + 5, clientY: mid.top + mid.height + 16 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ // origin should remain same on y for bottom handle
+ expect(after.top).toBe(mid.top)
+ expect(after.height).toBe(snap8(mid.height + 16))
+ })
+
+ it('corner NE handle adjusts width and height with appropriate origin shift on Y', async () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
+ const obj = await screen.findByTestId(/obj-text-/)
+ fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
+
+ const before = getBox()
+ const hNE = await screen.findByTestId(/resize-handle-ne-/)
+ fireEvent.mouseDown(hNE, { clientX: before.left + before.width, clientY: before.top })
+ fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + before.width + 16, clientY: before.top - 16 })
+ fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
+
+ const after = getBox()
+ // NE: primary checks are top shifted up and width increased; left origin may adjust under snapping
+ const expectedTop2 = Math.min(snap8(before.top - 16), before.top + before.height - 8)
+ expect(after.top).toBe(expectedTop2)
+ expect(after.width).toBe(snap8(before.width + 16))
+ expect(after.height).toBe((before.top + before.height) - expectedTop2)
+ })
+})
diff --git a/components/wysiwyg/WysiwygBuilderPage.test.tsx b/components/wysiwyg/WysiwygBuilderPage.test.tsx
index d9dddac..a5bcbf6 100644
--- a/components/wysiwyg/WysiwygBuilderPage.test.tsx
+++ b/components/wysiwyg/WysiwygBuilderPage.test.tsx
@@ -1,26 +1,26 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } 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
+let spyCreate: ReturnType | null = null
+let spyRevoke: ReturnType | null = null
describe('WysiwygBuilderPage', () => {
beforeEach(() => {
- // @ts-ignore
- global.URL.createObjectURL = () => 'blob:mock'
- // @ts-ignore
- global.URL.revokeObjectURL = () => {}
+ spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
+ spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
})
afterEach(() => {
- global.URL.createObjectURL = createObjectURL
- global.URL.revokeObjectURL = revokeObjectURL
+ spyCreate?.mockRestore()
+ spyRevoke?.mockRestore()
+ spyCreate = null
+ spyRevoke = null
})
it('adds a text object to the canvas', async () => {
render()
- const addText = screen.getByRole('button', { name: /Add Text/i })
+ const addText = screen.getByRole('button', { name: /^Add Text$/i })
fireEvent.click(addText)
await waitFor(() => {
expect(screen.getByTestId('obj-text-1')).toBeInTheDocument()
diff --git a/components/wysiwyg/WysiwygBuilderPage.tsx b/components/wysiwyg/WysiwygBuilderPage.tsx
index c8c91e9..4f7c8a6 100644
--- a/components/wysiwyg/WysiwygBuilderPage.tsx
+++ b/components/wysiwyg/WysiwygBuilderPage.tsx
@@ -39,11 +39,18 @@ function download(filename: string, data: Uint8Array) {
let textCounter = 1
let imageCounter = 1
let buttonCounter = 1
+let inputCounter = 1
+let selectCounter = 1
+let textareaCounter = 1
+let radioCounter = 1
+let checkboxCounter = 1
+let layerCounter = 1
export function WysiwygBuilderPage() {
const [frame, setFrame] = useState(() => emptyFrame())
const assetManagerRef = useRef(null)
const [selectedIds, setSelectedIds] = useState([])
+ const [guidesOn, setGuidesOn] = useState(true)
const selectedId = selectedIds[selectedIds.length - 1] || null
const selectedObject: WObject | null = useMemo(() => {
for (const ly of frame.layers) {
@@ -66,6 +73,62 @@ export function WysiwygBuilderPage() {
setSelectedIds([id])
}, [])
+ const addRadio = useCallback(() => {
+ const id = `radio-${radioCounter++}`
+ const obj: WObject = { id, type: 'radio', x: 60, y: 240, width: 300, height: 96, rotate: 0, zIndex: 0, props: { name: 'choice', legend: 'Legend', options: ['A','B','C'], defaultValue: 'A', required: false } }
+ setFrame((prev) => ({
+ ...prev,
+ layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
+ }))
+ setSelectedIds([id])
+ }, [])
+
+ const addCheckbox = useCallback(() => {
+ const id = `checkbox-${checkboxCounter++}`
+ const obj: WObject = { id, type: 'checkbox', x: 60, y: 360, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'agree', legend: 'Legend', options: ['One','Two','Three'], defaultValues: ['One'], required: false } }
+ setFrame((prev) => ({
+ ...prev,
+ layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
+ }))
+ setSelectedIds([id])
+ }, [])
+
+ const addTextarea = useCallback(() => {
+ const id = `textarea-${textareaCounter++}`
+ const obj: WObject = { id, type: 'textarea', x: 60, y: 180, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'message', label: 'Message', placeholder: '', rows: 3, required: false } }
+ setFrame((prev) => ({
+ ...prev,
+ layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
+ }))
+ setSelectedIds([id])
+ }, [])
+
+ const addSelect = useCallback(() => {
+ const id = `select-${selectCounter++}`
+ const obj: WObject = { id, type: 'select', x: 60, y: 120, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', options: ['S','M','L'], defaultValue: '' } }
+ setFrame((prev) => ({
+ ...prev,
+ layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
+ }))
+ setSelectedIds([id])
+ }, [])
+
+ const addLayer = useCallback(() => {
+ const lid = `layer-${layerCounter++}`
+ const tid = `text-${textCounter++}`
+ const layer: Frame['layers'][number] = {
+ id: lid,
+ name: `Layer ${layerCounter - 1}`,
+ visible: true,
+ locked: false,
+ objects: [
+ { id: tid, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: `L${layerCounter - 1}`, fontSize: 18, color: '#ffffff' } },
+ ],
+ }
+ setFrame((prev) => ({ ...prev, layers: [...prev.layers, layer] }))
+ setSelectedIds([tid])
+ }, [])
+
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: '' } }
@@ -122,6 +185,16 @@ export function WysiwygBuilderPage() {
setSelectedIds([id])
}, [])
+ const addInput = useCallback(() => {
+ const id = `input-${inputCounter++}`
+ const obj: WObject = { id, type: 'input', x: 60, y: 60, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', placeholder: '', required: false } }
+ 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,
@@ -152,6 +225,7 @@ export function WysiwygBuilderPage() {
+
+
+
+
+
+
@@ -169,10 +248,20 @@ export function WysiwygBuilderPage() {
WYSIWYG Builder
-
+
+
+
+
-
+
diff --git a/lib/wysiwyg/export.form.help.test.ts b/lib/wysiwyg/export.form.help.test.ts
new file mode 100644
index 0000000..eea80ac
--- /dev/null
+++ b/lib/wysiwyg/export.form.help.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest'
+import { exportFrame } from '@/lib/wysiwyg/export'
+import type { Frame } from '@/lib/wysiwyg/schema'
+
+function baseFrame(): Frame {
+ return {
+ id: 'f', width: 400, height: 300, background: '#ffffff',
+ layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
+ }
+}
+
+describe('wysiwyg export - forms aria-describedby(help)', () => {
+ it('input: adds help element and aria-describedby linkage', () => {
+ const f = baseFrame()
+ f.layers[0].objects.push({ id: 'in1', type: 'input', x: 10, y: 20, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: '', required: false, help: 'We will not spam you.' } })
+ const out = exportFrame(f)
+ expect(out.html).toMatch(/
[\s\S]*
[\s\S]*
{
+ const f = baseFrame()
+ f.layers[0].objects.push({ id: 'sel1', type: 'select', x: 0, y: 0, width: 100, height: 30, rotate: 0, zIndex: 0, props: { name: 'size', label: 'Size', options: ['S'], defaultValue: 'S', help: 'Choose wisely.' } })
+ const out = exportFrame(f)
+ expect(out.html).toMatch(/