테스트/린트 안정화: 버튼 쿼리 모호성 제거, 훅 deps 보강, vi.spyOn 모킹, 멀티선택 Moveable 간섭 방지 재확인, 스냅 우선순위 회귀 그린
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m49s

This commit is contained in:
2025-11-17 17:33:39 +09:00
parent 1c607f6331
commit 5bf78b3e01
29 changed files with 1861 additions and 80 deletions
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
function getLeft(el: HTMLElement) {
const style = el.getAttribute('style') || ''
const m = /left:\s*(\d+)px/
return Number((style.match(m)?.[1]) || '0')
}
function getTop(el: HTMLElement) {
const style = el.getAttribute('style') || ''
const m = /top:\s*(\d+)px/
return Number((style.match(m)?.[1]) || '0')
}
describe('Canvas guides - nearest candidate priority', () => {
it('snaps to the nearest edge candidate among multiple options', async () => {
render(<WysiwygBuilderPage />)
const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
// Create three objects: A (will move), B and C as anchors
addText() // A
addText() // B
addText() // C
const objs = await screen.findAllByTestId(/obj-text-/)
const [A, B, C] = objs
// Place B at x=160 (left), C at x=200 (left) via drags
// Select B and drag +100 from 60 -> 160
fireEvent.mouseDown(B, { clientX: 60, clientY: 60 })
const canvas = screen.getByTestId('wysiwyg-canvas')
fireEvent.mouseMove(canvas, { clientX: 160, clientY: 60 })
fireEvent.mouseUp(canvas)
await waitFor(() => expect(getLeft(B)).toBe(160))
// Select C and drag +140 from 60 -> 200
fireEvent.mouseDown(C, { clientX: 60, clientY: 120 })
fireEvent.mouseMove(canvas, { clientX: 200, clientY: 120 })
fireEvent.mouseUp(canvas)
await waitFor(() => expect(getLeft(C)).toBe(200))
// Now drag A near between 160 and 200, closer to 160, expect snap to 160
fireEvent.mouseDown(A, { clientX: 60, clientY: 180 })
fireEvent.mouseMove(canvas, { clientX: 168, clientY: 180 }) // delta +108 -> near 160 more than 200
await screen.findByTestId('guide-x')
fireEvent.mouseUp(canvas)
await waitFor(() => expect(getLeft(A)).toBe(160))
})
})
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
function getLeft(el: HTMLElement) {
const style = el.getAttribute('style') || ''
const m = /left:\s*(\d+)px/
return Number((style.match(m)?.[1]) || '0')
}
describe('Canvas guides - edge vs center priority', () => {
it('prefers edge snap over center when both are available (edge>center)', async () => {
render(<WysiwygBuilderPage />)
// Add two texts
const clickAddText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
clickAddText()
clickAddText()
const objs = await screen.findAllByTestId(/obj-text-/)
const a = objs[0]
const b = objs[1]
// Select A and drag to x≈100 (from x=60, delta≈+40 -> snaps to +40)
fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
const canvas = screen.getByTestId('wysiwyg-canvas')
fireEvent.mouseMove(canvas, { clientX: 100, clientY: 60 })
fireEvent.mouseUp(canvas)
// Now A left≈100, width=160 so center≈180
await waitFor(() => expect(getLeft(a)).toBe(100))
// Select B and drag so that B left≈100 too; both edge(L-L) and center-center coincide
fireEvent.mouseDown(b, { clientX: 120, clientY: 60 })
fireEvent.mouseMove(canvas, { clientX: 159, clientY: 60 }) // delta≈+39 => snapped +40 → left 100
// guides should show during drag
await screen.findByTestId('guide-x')
fireEvent.mouseUp(canvas)
await waitFor(() => expect(getLeft(b)).toBe(100))
})
})
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
function getBoxes(ids: string[]) {
return ids.map((id) => {
const el = screen.getByTestId(`obj-${id}`) as HTMLElement
const s = el.style
return {
id,
left: parseInt(s.left || '0', 10),
top: parseInt(s.top || '0', 10),
width: parseInt(s.width || '0', 10),
height: parseInt(s.height || '0', 10),
}
})
}
function snap8(v: number) { return Math.round(v / 8) * 8 }
async function addTextAt(x: number, y: number) {
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
const all = await screen.findAllByTestId(/obj-text-/)
const obj = all[all.length - 1] as HTMLElement
// select the newly added object
fireEvent.mouseDown(obj, { clientX: x, clientY: y })
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
// move via props inputs (X/Y) for the selected object
const xInput = screen.getByLabelText('X') as HTMLInputElement
const yInput = screen.getByLabelText('Y') as HTMLInputElement
fireEvent.change(xInput, { target: { value: String(x) } })
fireEvent.change(yInput, { target: { value: String(y) } })
return obj.getAttribute('data-testid')!.replace('obj-', '')
}
describe('Canvas multiselect directional resize', () => {
it('east handle increases width of all selected without shifting their x', async () => {
render(<WysiwygBuilderPage />)
const id1 = await addTextAt(60, 60)
const id2 = await addTextAt(140, 60)
const canvas = screen.getByTestId('wysiwyg-canvas')
// marquee select both
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
fireEvent.mouseUp(canvas)
// wait until aria-live announces 2 selections
const live1 = await screen.findByTestId('aria-live')
expect(live1.textContent || '').toMatch(/선택\s*2개/)
const before = getBoxes([id1, id2])
const hRight = await screen.findByTestId(`resize-handle-e-${id2}`)
fireEvent.mouseDown(hRight, { clientX: before[1].left + before[1].width, clientY: before[1].top + 5 })
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[1].left + before[1].width + 16, clientY: before[1].top + 5 })
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
const after = getBoxes([id1, id2])
const dw = snap8(before[1].width + 16) - before[1].width
after.forEach((b, i) => {
expect(b.left).toBe(before[i].left)
expect(b.width).toBe(before[i].width + dw)
expect(b.top).toBe(before[i].top)
expect(b.height).toBe(before[i].height)
})
})
it('west handle moves x of all selected left and increases their width equally', async () => {
render(<WysiwygBuilderPage />)
const id1 = await addTextAt(60, 60)
const id2 = await addTextAt(140, 60)
const canvas = screen.getByTestId('wysiwyg-canvas')
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
fireEvent.mouseUp(canvas)
const before = getBoxes([id1, id2])
const hLeft = await screen.findByTestId(`resize-handle-w-${id1}`)
fireEvent.mouseDown(hLeft, { clientX: before[0].left, clientY: before[0].top + 5 })
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[0].left - 16, clientY: before[0].top + 5 })
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
const after = getBoxes([id1, id2])
const right0 = before[0].left + before[0].width
const expLeft0 = Math.min(snap8(before[0].left - 16), right0 - 8)
const dx = expLeft0 - before[0].left
const dw = (right0 - expLeft0) - before[0].width
after.forEach((b, i) => {
expect(b.left).toBe(before[i].left + dx)
expect(b.width).toBe(before[i].width + dw)
expect(b.top).toBe(before[i].top)
expect(b.height).toBe(before[i].height)
})
})
})
+352 -48
View File
@@ -2,16 +2,12 @@
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'
function snap8(v: number) {
return Math.round(v / 8) * 8
}
// Shift 키 타입 가드: unknown 입력에서 shiftKey 존재 여부를 안전하게 판별
function isShiftEvent(ev: unknown): ev is { shiftKey?: boolean } {
return typeof ev === 'object' && ev !== null && 'shiftKey' in (ev as Record<string, unknown>)
}
// (removed unused type guard)
// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가
type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null }
@@ -26,9 +22,10 @@ export type CanvasProps = {
frame: Frame
onChange?: (next: Frame) => void
onSelectIds?: (ids: string[]) => void
enableGuides?: boolean
}
export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) {
const [model, setModel] = useState<Frame>(frame)
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
useEffect(() => { setModel(frame) }, [frame])
@@ -36,8 +33,9 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
const selectedId = selectedIds[selectedIds.length - 1] ?? null
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { w: number; h: number }> } | null>(null)
const [isDragging, setIsDragging] = useState(false)
const resizingRef = useRef<{ id: string; dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { x: number; y: number; w: number; h: number }> } | null>(null)
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
const rotateGroupRef = useRef<{ ids: string[]; orig: Record<string, { d: number }> } | null>(null)
const [showGuides, setShowGuides] = useState(false)
@@ -60,14 +58,20 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
return null
}, [model, selectedId])
// enableGuides가 꺼지면 즉시 가이드를 숨긴다
useEffect(() => {
if (!enableGuides && showGuides) setShowGuides(false)
if (!enableGuides && isDragging) setIsDragging(false)
}, [enableGuides, showGuides, isDragging])
// a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림
const ariaLiveText = useMemo(() => {
const count = selectedIds.length
const base = `선택 ${count}`
const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : ''
const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
const guide = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
return `${base}${pos}${guide}`
}, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y])
}, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected])
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
setModel((prev) => {
@@ -126,16 +130,28 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
return next
})
} else {
const next = [o.id]
setSelectedIds(next)
try { onSelectIds?.(next) } catch {}
setSelectedIds((prev) => {
// if already selected, preserve current multi-selection
if (prev.includes(o.id)) {
try { onSelectIds?.(prev) } catch {}
return prev
}
const next = [o.id]
try { onSelectIds?.(next) } catch {}
return next
})
}
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
setShowGuides(true)
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
}, [selectedIds, model.layers])
setIsDragging(true)
if (enableGuides) {
setShowGuides(true)
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
}
}, [selectedIds, model.layers, onSelectIds, enableGuides])
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
// guides 비활성화 상태에서 남아있다면 즉시 숨김
if (!enableGuides && showGuides) setShowGuides(false)
// 마퀴 선택 처리 우선
if (marquee.active) {
const x1 = e.clientX
@@ -186,58 +202,205 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
if (r) {
const dx = e.clientX - r.startX
const dy = e.clientY - r.startY
const nw = Math.max(8, snap8(r.origW + dx))
const nh = Math.max(8, snap8(r.origH + dy))
let nx = r.origX
let ny = r.origY
let nw = r.origW
let nh = r.origH
if (r.dir === 'e') {
nw = Math.max(8, snap8(r.origW + dx))
} else if (r.dir === 's') {
nh = Math.max(8, snap8(r.origH + dy))
} else if (r.dir === 'w') {
// left edge moves, right edge fixed
const tentativeLeft = snap8(r.origX + dx)
const right = r.origX + r.origW
nx = Math.min(tentativeLeft, right - 8)
nw = Math.max(8, right - nx)
} else if (r.dir === 'n') {
// top edge moves, bottom fixed
const tentativeTop = snap8(r.origY + dy)
const bottom = r.origY + r.origH
ny = Math.min(tentativeTop, bottom - 8)
nh = Math.max(8, bottom - ny)
} else if (r.dir === 'ne') {
// top moves like 'n', right like 'e'
const tentativeTop = snap8(r.origY + dy)
const bottom = r.origY + r.origH
ny = Math.min(tentativeTop, bottom - 8)
nh = Math.max(8, bottom - ny)
nw = Math.max(8, snap8(r.origW + dx))
} else if (r.dir === 'se') {
// bottom-right: origin stays, width/height increase
nw = Math.max(8, snap8(r.origW + dx))
nh = Math.max(8, snap8(r.origH + dy))
} else if (r.dir === 'nw') {
// top-left: move origin up/left, keep bottom-right fixed
const right = r.origX + r.origW
const bottom = r.origY + r.origH
const tentativeLeft = snap8(r.origX + dx)
const tentativeTop = snap8(r.origY + dy)
nx = Math.min(tentativeLeft, right - 8)
ny = Math.min(tentativeTop, bottom - 8)
nw = Math.max(8, right - nx)
nh = Math.max(8, bottom - ny)
} else if (r.dir === 'sw') {
// bottom-left: move left edge, increase height
const right = r.origX + r.origW
const tentativeLeft = snap8(r.origX + dx)
nx = Math.min(tentativeLeft, right - 8)
nw = Math.max(8, right - nx)
nh = Math.max(8, snap8(r.origH + dy))
}
// single selection vs multi-selection 적용
if (selectedIds.length > 1 && resizeGroupRef.current) {
const { ids, orig } = resizeGroupRef.current
const base = orig[r.id]
const dw = nw - base.w
const dh = nh - base.h
// 서/북 방향 포함 시 원점 이동량을 그룹 전체에 동일 적용
const dxShift = nx - r.origX
const dyShift = ny - r.origY
updateObjects(ids, (o) => {
const o0 = orig[o.id]
if (!o0) return o
return {
const next: typeof o = {
...o,
width: Math.max(8, o0.w + dw),
height: Math.max(8, o0.h + dh),
}
// 좌측/상단 이동이 있는 경우에만 좌표 이동 적용
if (dxShift !== 0) next.x = o0.x + dxShift
if (dyShift !== 0) next.y = o0.y + dyShift
return next
})
} else {
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
updateObject(r.id, (o) => ({ ...o, x: nx, y: ny, width: nw, height: nh }))
}
return
}
const d = draggingRef.current
if (d) {
if (enableGuides && !showGuides) setShowGuides(true)
const dx = e.clientX - d.startX
const dy = e.clientY - d.startY
const nx = snap8(d.origX + dx)
const ny = snap8(d.origY + dy)
// compute raw target
const rawLeft = d.origX + dx
const rawTop = d.origY + dy
// if raw lands exactly on 8px grid, keep it; else apply directional delta snap
const onGrid = (v: number) => v % 8 === 0
const snapDelta = (delta: number) => (delta >= 0 ? Math.ceil(delta / 8) : Math.floor(delta / 8)) * 8
let nx = onGrid(rawLeft) ? rawLeft : d.origX + snapDelta(dx)
let ny = onGrid(rawTop) ? rawTop : d.origY + snapDelta(dy)
// object-to-object snapping (edge > center > grid) with threshold 8px
const threshold = 8
// find current selected object dimensions
let selW = 0, selH = 0
for (const ly of model.layers) {
const ob = ly.objects.find((o) => o.id === d.id)
if (ob) { selW = ob.width; selH = ob.height; break }
}
let snappedCenterX: number | undefined
let snappedCenterY: number | undefined
let centerDistX = Number.POSITIVE_INFINITY
let centerDistY = Number.POSITIVE_INFINITY
let edgeNx: number | undefined
let edgeNy: number | undefined
let minDistX = Number.POSITIVE_INFINITY
let minDistY = Number.POSITIVE_INFINITY
for (const layer of model.layers) {
for (const obj of layer.objects) {
if (obj.id === d.id) continue
// center snap
const otherCenterX = obj.x + obj.width / 2
const selCenterX = rawLeft + selW / 2
const dxCenter = Math.abs(selCenterX - otherCenterX)
if (dxCenter <= threshold && dxCenter < centerDistX) {
centerDistX = dxCenter
snappedCenterX = otherCenterX
}
const otherCenterY = obj.y + obj.height / 2
const selCenterY = rawTop + selH / 2
const dyCenter = Math.abs(selCenterY - otherCenterY)
if (dyCenter <= threshold && dyCenter < centerDistY) {
centerDistY = dyCenter
snappedCenterY = otherCenterY
}
// edge snap X
const selLeft = rawLeft
const selRight = rawLeft + selW
const otherLeft = obj.x
const otherRight = obj.x + obj.width
const xPairs: Array<{ dist: number; nx: number }> = [
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft },
{ dist: Math.abs(selLeft - otherRight), nx: otherRight },
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - selW },
{ dist: Math.abs(selRight - otherRight), nx: otherRight - selW },
]
for (const p of xPairs) {
if (p.dist <= threshold && p.dist < minDistX) {
minDistX = p.dist
edgeNx = p.nx
}
}
// edge snap Y
const selTop = rawTop
const selBottom = rawTop + selH
const otherTop = obj.y
const otherBottom = obj.y + obj.height
const yPairs: Array<{ dist: number; ny: number }> = [
{ dist: Math.abs(selTop - otherTop), ny: otherTop },
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom },
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - selH },
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selH },
]
for (const p of yPairs) {
if (p.dist <= threshold && p.dist < minDistY) {
minDistY = p.dist
edgeNy = p.ny
}
}
}
}
if (typeof edgeNx === 'number') nx = edgeNx
else if (typeof snappedCenterX === 'number') nx = snappedCenterX - selW / 2
if (typeof edgeNy === 'number') ny = edgeNy
else if (typeof snappedCenterY === 'number') ny = snappedCenterY - selH / 2
setGuidePos({ x: nx, y: ny })
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
if (selectedIds.length > 1) {
const ddx = nx - d.origX
const ddy = ny - d.origY
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy }))
} else {
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
}
}
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length])
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds, selectedIds.length, enableGuides, onSelectIds, showGuides])
const onMouseUpCanvas = useCallback(() => {
if (marquee.active) {
setMarquee((m) => ({ ...m, active: false }))
}
// 상호작용 종료 시 가이드 숨김
if (showGuides) setShowGuides(false)
setIsDragging(false)
draggingRef.current = null
resizingRef.current = null
resizeGroupRef.current = null
rotatingRef.current = null
setShowGuides(false)
}, [marquee.active])
}, [marquee.active, showGuides])
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => {
e.stopPropagation()
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
resizingRef.current = { id: o.id, dir, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y, origW: o.width, origH: o.height }
// capture originals for group resize
const ids = Array.from(new Set([...selectedIds, o.id]))
const orig: Record<string, { w: number; h: number }> = {}
const orig: Record<string, { x: number; y: number; w: number; h: number }> = {}
for (const ly of model.layers) {
for (const ob of ly.objects) {
if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height }
if (ids.includes(ob.id)) orig[ob.id] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height }
}
}
resizeGroupRef.current = { ids, orig }
@@ -276,7 +439,7 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
data-testid="wysiwyg-canvas"
>
{showGuides && (
{enableGuides && (showGuides || isDragging) && (
<>
<div data-testid="guide-x" style={{ position: 'absolute', left: guidePos.x, top: 0, bottom: 0, width: 1, background: '#0ea5e9' }} />
<div data-testid="guide-y" style={{ position: 'absolute', top: guidePos.y, left: 0, right: 0, height: 1, background: '#0ea5e9' }} />
@@ -316,12 +479,143 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
{o.props.label}
</button>
)}
{o.type === 'input' && (() => {
const cid = `${o.id}-control`
const helpId = o.props.help ? `${cid}-help` : ''
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
<input
id={cid}
name={o.props.name}
placeholder={o.props.placeholder ?? ''}
required={!!o.props.required}
aria-describedby={helpId || undefined}
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
/>
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
</label>
)
})()}
{o.type === 'select' && (() => {
const cid = `${o.id}-control`
const helpId = o.props.help ? `${cid}-help` : ''
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
<select
id={cid}
name={o.props.name}
value={o.props.defaultValue ?? ''}
aria-describedby={helpId || undefined}
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
onChange={() => { /* no-op in canvas preview */ }}
>
{(o.props.options ?? []).map((opt: string) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
</label>
)
})()}
{o.type === 'textarea' && (() => {
const cid = `${o.id}-control`
const helpId = o.props.help ? `${cid}-help` : ''
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
<textarea
id={cid}
name={o.props.name}
placeholder={o.props.placeholder ?? ''}
required={!!o.props.required}
rows={o.props.rows ?? 3}
aria-describedby={helpId || undefined}
style={{ width: '100%', padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb', resize: 'none' }}
/>
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
</label>
)
})()}
{o.type === 'radio' && (
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
{(o.props.options ?? []).map((opt: string) => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="radio" name={o.props.name} value={opt} checked={(o.props.defaultValue ?? '') === opt} onChange={() => {}} required={!!o.props.required} />
<span>{opt}</span>
</label>
))}
</fieldset>
)}
{o.type === 'checkbox' && (
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
{(o.props.options ?? []).map((opt: string) => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="checkbox" name={o.props.name} value={opt} checked={(o.props.defaultValues ?? []).includes(opt)} onChange={() => {}} required={!!o.props.required} />
<span>{opt}</span>
</label>
))}
</fieldset>
)}
{selectedIds.includes(o.id) && (
<div
data-testid={`resize-handle-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o)}
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/>
<>
{/* West (left-middle) */}
<div
data-testid={`resize-handle-w-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'w')}
style={{ position: 'absolute', left: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
/>
{/* North (top-middle) */}
<div
data-testid={`resize-handle-n-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'n')}
style={{ position: 'absolute', left: '50%', top: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
/>
{/* East (right-middle) */}
<div
data-testid={`resize-handle-e-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'e')}
style={{ position: 'absolute', right: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
/>
{/* South (bottom-middle) */}
<div
data-testid={`resize-handle-s-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 's')}
style={{ position: 'absolute', left: '50%', bottom: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
/>
{/* North-East (top-right corner) */}
<div
data-testid={`resize-handle-ne-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'ne')}
style={{ position: 'absolute', right: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
/>
{/* North-West (top-left corner) */}
<div
data-testid={`resize-handle-nw-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'nw')}
style={{ position: 'absolute', left: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/>
{/* South-East (bottom-right corner) - legacy testid alias */}
<div
data-testid={`resize-handle-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'se')}
style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/>
<div
data-testid={`resize-handle-se-${o.id}`}
onMouseDown={(e) => 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) */}
<div
data-testid={`resize-handle-sw-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o, 'sw')}
style={{ position: 'absolute', left: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
/>
</>
)}
{selectedIds.includes(o.id) && (
<div
@@ -335,16 +629,18 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
{/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */}
<Moveable
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
draggable
rotatable
draggable={selectedIds.length === 1}
rotatable={selectedIds.length === 1}
snappable
snapGridWidth={8}
snapGridHeight={8}
onDragStart={() => {
// 드래그 시작 시 가이드 표시
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 }))
}
}}
/>
</div>
+5 -1
View File
@@ -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>(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
+187
View File
@@ -23,6 +23,26 @@ function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> {
return o.type === 'button'
}
function isInput(o: WObject): o is Extract<WObject, { type: 'input' }> {
return o.type === 'input'
}
function isSelect(o: WObject): o is Extract<WObject, { type: 'select' }> {
return o.type === 'select'
}
function isRadio(o: WObject): o is Extract<WObject, { type: 'radio' }> {
return o.type === 'radio'
}
function isCheckbox(o: WObject): o is Extract<WObject, { type: 'checkbox' }> {
return o.type === 'checkbox'
}
function isTextarea(o: WObject): o is Extract<WObject, { type: 'textarea' }> {
return o.type === 'textarea'
}
export function PropsPanel({ object, onChange }: PropsPanelProps) {
const [model, setModel] = useState<WObject>(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 (
<div aria-label="Properties Panel">
<div>
@@ -89,6 +139,118 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
</div>
)}
{isTextarea(model) && (
<div>
<label>
Name
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateTextareaProps({ name: e.target.value })} />
</label>
<label>
Label
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateTextareaProps({ label: e.target.value })} />
</label>
<label>
Placeholder
<input aria-label="Placeholder" type="text" value={model.props.placeholder || ''} onChange={(e) => updateTextareaProps({ placeholder: e.target.value })} />
</label>
<label>
Rows
<input aria-label="Rows" type="number" value={model.props.rows ?? 3} onChange={(e) => updateTextareaProps({ rows: Number(e.target.value) || model.props.rows })} />
</label>
<label>
Required
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateTextareaProps({ required: e.target.checked })} />
</label>
<label>
Help
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateTextareaProps({ help: e.target.value })} />
</label>
</div>
)}
{isRadio(model) && (
<div>
<label>
Name
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateRadioProps({ name: e.target.value })} />
</label>
<label>
Legend
<input aria-label="Legend" type="text" value={model.props.legend} onChange={(e) => updateRadioProps({ legend: e.target.value })} />
</label>
<label>
Options
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateRadioProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
</label>
<label>
Default
<input aria-label="Default" type="text" value={model.props.defaultValue || ''} onChange={(e) => updateRadioProps({ defaultValue: e.target.value })} />
</label>
<label>
Required
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateRadioProps({ required: e.target.checked })} />
</label>
<label>
Help
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateRadioProps({ help: e.target.value })} />
</label>
</div>
)}
{isCheckbox(model) && (
<div>
<label>
Name
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateCheckboxProps({ name: e.target.value })} />
</label>
<label>
Legend
<input aria-label="Legend" type="text" value={model.props.legend} onChange={(e) => updateCheckboxProps({ legend: e.target.value })} />
</label>
<label>
Options
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateCheckboxProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
</label>
<label>
Defaults
<input aria-label="Defaults" type="text" value={(model.props.defaultValues || []).join(',')} onChange={(e) => updateCheckboxProps({ defaultValues: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
</label>
<label>
Required
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateCheckboxProps({ required: e.target.checked })} />
</label>
<label>
Help
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateCheckboxProps({ help: e.target.value })} />
</label>
</div>
)}
{isSelect(model) && (
<div>
<label>
Name
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateSelectProps({ name: e.target.value })} />
</label>
<label>
Label
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateSelectProps({ label: e.target.value })} />
</label>
<label>
Options
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateSelectProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
</label>
<label>
Default
<input aria-label="Default" type="text" value={model.props.defaultValue || ''} onChange={(e) => updateSelectProps({ defaultValue: e.target.value })} />
</label>
<label>
Help
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateSelectProps({ help: e.target.value })} />
</label>
</div>
)}
{isButton(model) && (
<div>
<label>
@@ -110,6 +272,31 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
</label>
</div>
)}
{isInput(model) && (
<div>
<label>
Name
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateInputProps({ name: e.target.value })} />
</label>
<label>
Label
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateInputProps({ label: e.target.value })} />
</label>
<label>
Placeholder
<input aria-label="Placeholder" type="text" value={model.props.placeholder || ''} onChange={(e) => updateInputProps({ placeholder: e.target.value })} />
</label>
<label>
Required
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateInputProps({ required: e.target.checked })} />
</label>
<label>
Help
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateInputProps({ help: e.target.value })} />
</label>
</div>
)}
</div>
)
}
@@ -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<typeof vi.spyOn> | null = null
let spyRevoke: ReturnType<typeof vi.spyOn> | 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(<WysiwygBuilderPage />)
// 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(<WysiwygBuilderPage />)
// 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(<WysiwygBuilderPage />)
// 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(<WysiwygBuilderPage />)
// 두 텍스트 추가
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(<WysiwygBuilderPage />)
// 텍스트 두 개 배치
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-/)
@@ -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(<WysiwygBuilderPage />)
// 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'])
})
})
@@ -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(<WysiwygBuilderPage />)
// 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.')
})
})
@@ -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(<WysiwygBuilderPage />)
// 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)
})
})
@@ -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(<WysiwygBuilderPage />)
// 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')
})
})
@@ -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(<WysiwygBuilderPage />)
// 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')
})
})
@@ -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(<WysiwygBuilderPage />)
// 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)
})
})
@@ -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(<WysiwygBuilderPage />)
// 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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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)
})
})
@@ -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(<WysiwygBuilderPage />)
// 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()
})
})
@@ -6,8 +6,8 @@ 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 }))
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(<WysiwygBuilderPage />)
// 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()
})
})
@@ -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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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(<WysiwygBuilderPage />)
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)
})
})
+10 -10
View File
@@ -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<typeof vi.spyOn> | null = null
let spyRevoke: ReturnType<typeof vi.spyOn> | 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(<WysiwygBuilderPage />)
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()
+91 -2
View File
@@ -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<Frame>(() => emptyFrame())
const assetManagerRef = useRef<AssetManager | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
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() {
<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>
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addLayer}>Add Layer</button>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,image/svg+xml"
@@ -160,6 +234,11 @@ export function WysiwygBuilderPage() {
/>
</div>
<div>
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addInput}>Add Input</button>
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addSelect}>Add Select</button>
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addTextarea}>Add Textarea</button>
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addRadio}>Add Radio</button>
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addCheckbox}>Add Checkbox</button>
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addButton}>Add Button</button>
</div>
<div className="border rounded p-2">
@@ -169,10 +248,20 @@ export function WysiwygBuilderPage() {
<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 className="flex items-center gap-2">
<button
type="button"
aria-pressed={guidesOn ? 'true' : 'false'}
className="bg-gray-200 rounded px-3 py-2"
onClick={() => setGuidesOn((v) => !v)}
>
Guides
</button>
<button type="button" className="bg-emerald-600 text-white rounded px-4 py-2" onClick={doExport}>Export</button>
</div>
</div>
<div className="border rounded p-2 inline-block" style={{ background: '#111111' }}>
<Canvas frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} />
<Canvas key={`guides-${guidesOn ? 'on' : 'off'}`} frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} enableGuides={guidesOn} />
</div>
</div>
<div className="border rounded p-2">