diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index ff27174..ad14137 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -1,5 +1,37 @@ # 메인플랜: 랜딩 페이지 빌더 +## [정렬] WYSIWYG 빌더 목표/요구사항/수용기준 (2025-11-17) +- **목표** + - /[locale]/wysiwyg 에서 비개발자도 랜딩을 시각적으로 제작(Add→편집→Export)할 수 있다. + - Export 산출물은 ZIP(index.html/css/js/assets/*, site.webmanifest, robots.txt, 무결성 파일)을 포함한다. + +- **확정 요구사항** + - R1: 텍스트/이미지/버튼 추가 및 속성 편집(X/Y/W/H/Rotate + 타입별 속성) + - R2: 조작 UX(드래그/리사이즈/회전, 8px/15° 스냅, 멀티선택·마퀴, 키보드 너지, 가이드 표시, a11y 라이브 리전) + - R3: 레이어 관리(순서 이동, lock, visible 토글) + - R4: Export ZIP 생성(무결성 파일 포함) 및 verify ok + - R5: 경로 고정(/[locale]/wysiwyg) + +- **수용 기준(DoD)** + - A1: /wysiwyg에서 Add Text → 속성 수정 → Export 실행 시 ZIP 생성, HTML/CSS에 수정 반영 + - A2: 드래그/리사이즈/회전 스냅 동작 및 가이드 표시 스모크 그린 + - A3: 라이브 리전 문구가 선택/가이드/너지에 맞춰 갱신 + - A4: 생성된 ZIP에 대해 verifyIntegrity 결과 ok=true + - A5: 레이어 순서 변경이 렌더에 반영 + +- **TDD 매핑** + - R1 → PropsPanel.test(기존) + WysiwygBuilderPage 추가/수정 반영 스모크(신규) + - R2 → Canvas.*.test(기존) + 빌더 멀티선택/마퀴 스모크(신규) + - R3 → LayersPanel.test(기존) + 빌더 순서 반영(신규) + - R4 → exportFrame/buildZip/무결성(기존) + 빌더 Export 스모크(신규) + - R5 → 라우트 스모크(기존) + +- **순차 작업 계획** + 1) 수용 테스트 추가(빌더 Add→Edit→Export, 레이어 순서 반영, 멀티선택/마퀴 스모크) + 2) /wysiwyg Export에 무결성 파일 포함 연동 + 3) 최소 UI 토글(가이드/그리드) 노출 및 문서 업데이트 + 4) 전체 그린 확인 후 브랜치 범위 점검 → 푸쉬 + ### 가이드라인 고도화 진행(2025-11-16) - 목표: 객체-객체 스냅 가이드(8px 임계) - 수직 중심 정렬 가이드/스냅 @@ -64,6 +96,32 @@ - 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검 - 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계 +### WYSIWYG 빌더 Export/이미지/Props 동기화 진행(2025-11-17) +- 목표: /wysiwyg에서 실제 랜딩에 가까운 HTML/CSS Export와 로컬 이미지 업로드, 속성 패널과 Canvas의 일관된 동기화 확보 +- TDD + - `lib/wysiwyg/export.test.ts` + - 버튼 오브젝트를 `label`로 내보내는지 검증 + - 숨김 레이어(`visible: false`)가 HTML/CSS에 포함되지 않는지 검증 + - `exportFrame` 결과 HTML이 ``, ``, ``를 포함하는 전체 문서 구조인지 검증 + - `components/wysiwyg/WysiwygBuilder.acceptance.test.tsx` + - 로컬 이미지 업로드 시 Canvas에 ``가 렌더되는지 검증 + - PropsPanel에서 Width/Height/Rotate 변경이 Canvas 스타일(width/height/transform)에 반영되는지 검증 + - PropsPanel에서 Text/Label 변경이 Canvas 텍스트/버튼 콘텐츠에 반영되는지 검증 +- 구현 + - `lib/wysiwyg/export.ts` + - frame 내용을 `
...
`로 감싼 뒤, `...styles.css......app.js...` 전체 문서로 래핑 + - 숨김 레이어는 HTML/CSS 생성 시 스킵, 버튼은 ``로 내보내되 위치/크기/회전은 CSS에서 절대 좌표로 표현 + - `components/wysiwyg/WysiwygBuilderPage.tsx` + - 페이지 수명 주기 동안 유지되는 `AssetManager`를 `useRef`로 보관 + - 파일 업로드 input(`data-testid="wysiwyg-image-input"`) 추가 후, 업로드된 이미지를 `AssetManager.add`로 수집하고 `props.src="/assets/..."`인 image WObject를 Base 레이어에 추가 + - Export 시 `assetManager.toZipStructure()`를 `buildZip`의 `assets`로 전달하고, 기존 무결성 파일(`assets.json`, `assets.index.json`, `assets.integrity.index.json`)을 extras에 포함 + - `components/wysiwyg/PropsPanel.tsx` + - 내부 `model`을 `object` prop과 `useEffect`로 동기화하여 선택 변경 시 최신 값 반영 + - Text용 Text 필드(`aria-label="Text"` → `props.text`), Button용 Label 필드(`aria-label="Label"` → `props.label`) 추가 +- 결과 + - /wysiwyg에서 Add Text/Image/Button, PropsPanel 편집, 로컬 이미지 업로드, Export ZIP(index.html/styles.css/app.js/assets/* + 무결성 파일)까지 한 플로우가 acceptance 테스트로 검증되는 상태 + - `npm test -s` 기준 145 파일 / 246 테스트 전체 그린 + ### 접근성 라이브 리전(2025-11-17) - 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내 - TDD diff --git a/app/[locale]/wysiwyg/page.tsx b/app/[locale]/wysiwyg/page.tsx index 5ae2566..954cbd4 100644 --- a/app/[locale]/wysiwyg/page.tsx +++ b/app/[locale]/wysiwyg/page.tsx @@ -1,5 +1,5 @@ -import WysiwygPage from '@/components/wysiwyg/WysiwygPage' +import WysiwygBuilderPage from '@/components/wysiwyg/WysiwygBuilderPage' export default function Page() { - return + return } diff --git a/components/wysiwyg/Canvas.moveable.events.test.tsx b/components/wysiwyg/Canvas.moveable.events.test.tsx index b9a969b..d3b44d9 100644 --- a/components/wysiwyg/Canvas.moveable.events.test.tsx +++ b/components/wysiwyg/Canvas.moveable.events.test.tsx @@ -71,8 +71,10 @@ describe('Canvas + Moveable events', () => { await act(async () => { last.onResize?.({ width: 83, height: 13 }) }) const obj = screen.getByTestId('obj-o1') as HTMLElement await waitFor(() => { + // 현재 리사이즈는 Moveable onResize가 아닌 커스텀 핸들에서만 처리되므로 + // Moveable onResize 호출은 객체 크기에 영향을 주지 않는다. expect(obj.style.width).toBe('80px') - expect(obj.style.height).toBe('16px') + expect(obj.style.height).toBe('30px') }) }) diff --git a/components/wysiwyg/Canvas.moveable.test.tsx b/components/wysiwyg/Canvas.moveable.test.tsx index e76d246..b0afdf8 100644 --- a/components/wysiwyg/Canvas.moveable.test.tsx +++ b/components/wysiwyg/Canvas.moveable.test.tsx @@ -47,7 +47,8 @@ describe('Canvas + Moveable integration (smoke)', () => { const props = JSON.parse(mv.getAttribute('data-props') || '{}') expect(props.draggable).toBe(true) - expect(props.resizable).toBe(true) + // 현재 리사이즈는 커스텀 핸들로만 처리하므로 Moveable에는 resizable을 주지 않는다. + expect(props.resizable).toBeUndefined() expect(props.rotatable).toBe(true) expect(props.snappable).toBe(true) expect(props.snapGridWidth).toBe(8) diff --git a/components/wysiwyg/Canvas.resize.behavior.test.tsx b/components/wysiwyg/Canvas.resize.behavior.test.tsx new file mode 100644 index 0000000..30ac72f --- /dev/null +++ b/components/wysiwyg/Canvas.resize.behavior.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f', + width: 400, + height: 300, + background: '#111111', + layers: [ + { + id: 'base', + name: 'Base', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'text', x: 40, y: 40, width: 80, height: 40, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#fff' } }, + { id: 'o2', type: 'text', x: 200, y: 40, width: 80, height: 40, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#fff' } }, + ], + }, + ], + } +} + +describe('Canvas resize behavior (단일 선택 UX)', () => { + it('단일 선택일 때 우하단 핸들 드래그로만 크기가 변하고, 다른 객체는 그대로 유지된다', () => { + const frame = makeFrame() + render() + const canvas = screen.getByTestId('wysiwyg-canvas') + const obj1 = screen.getByTestId('obj-o1') as HTMLDivElement + const obj2 = screen.getByTestId('obj-o2') as HTMLDivElement + + // o1만 선택 + fireEvent.mouseDown(obj1, { clientX: 50, clientY: 50 }) + + const handle = screen.getByTestId('resize-handle-o1') + // 우하단 방향으로 드래그 → width/height 증가 + fireEvent.mouseDown(handle, { clientX: 120, clientY: 80 }) + fireEvent.mouseMove(canvas, { clientX: 180, clientY: 140 }) + fireEvent.mouseUp(canvas) + + const w1 = parseInt(obj1.style.width, 10) + const h1 = parseInt(obj1.style.height, 10) + expect(w1).toBeGreaterThan(80) + expect(h1).toBeGreaterThan(40) + + // o2는 변하지 않는다 + const w2 = parseInt(obj2.style.width, 10) + const h2 = parseInt(obj2.style.height, 10) + expect(w2).toBe(80) + expect(h2).toBe(40) + + // 반대 방향으로 드래그하면 줄어든다 + const handle2 = screen.getByTestId('resize-handle-o1') + fireEvent.mouseDown(handle2, { clientX: 180, clientY: 140 }) + fireEvent.mouseMove(canvas, { clientX: 130, clientY: 100 }) + fireEvent.mouseUp(canvas) + const w1b = parseInt(obj1.style.width, 10) + const h1b = parseInt(obj1.style.height, 10) + expect(w1b).toBeLessThan(w1) + expect(h1b).toBeLessThan(h1) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 8cbf225..63c1ac3 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react' +"use client" +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap' @@ -24,10 +25,13 @@ if (typeof document !== 'undefined') { export type CanvasProps = { frame: Frame onChange?: (next: Frame) => void + onSelectIds?: (ids: string[]) => void } -export function Canvas({ frame, onChange }: CanvasProps) { +export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { const [model, setModel] = useState(frame) + // 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화 + useEffect(() => { setModel(frame) }, [frame]) const initialId = model.layers[0]?.objects[0]?.id ?? null const [selectedIds, setSelectedIds] = useState(initialId ? [initialId] : []) const selectedId = selectedIds[selectedIds.length - 1] ?? null @@ -117,10 +121,14 @@ export function Canvas({ frame, onChange }: CanvasProps) { setSelectedIds((prev) => { const exists = prev.includes(o.id) if (exists) return prev.filter((id) => id !== o.id) - return [...prev, o.id] + const next = [...prev, o.id] + try { onSelectIds?.(next) } catch {} + return next }) } else { - setSelectedIds([o.id]) + const next = [o.id] + setSelectedIds(next) + try { onSelectIds?.(next) } catch {} } draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y } setShowGuides(true) @@ -150,6 +158,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { } } setSelectedIds(hits) + try { onSelectIds?.(hits) } catch {} return } const rot = rotatingRef.current @@ -259,6 +268,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') { setSelectedIds([]) setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }) + try { onSelectIds?.([]) } catch {} } }} onMouseMove={onMouseMoveCanvas} @@ -280,8 +290,32 @@ export function Canvas({ frame, onChange }: CanvasProps) { aria-label={`object ${o.id}`} onMouseDown={(e) => onMouseDownObj(e, o)} ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined} - style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none' }} + style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none', border: '1px solid rgba(148,163,184,0.6)', background: 'rgba(15,23,42,0.8)', color: '#f9fafb', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 4, boxSizing: 'border-box' }} > + {/* 실제 오브젝트 콘텐츠 렌더링 */} + {o.type === 'text' && ( +

{o.props.text}

+ )} + {o.type === 'image' && ( + {o.props.alt + )} + {o.type === 'button' && ( + + )} {selectedIds.includes(o.id) && (
{ - if (!selected) return - const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0)) - const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0)) - if (selectedIds.length > 1) { - const dw = nw - selected.width - const dh = nh - selected.height - updateObjects(selectedIds, (o) => ({ - ...o, - width: Math.max(8, o.width + dw), - height: Math.max(8, o.height + dh), - })) - } else { - updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh })) - } - }} onRotate={({ rotate, inputEvent }) => { if (!selected) return const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey diff --git a/components/wysiwyg/PropsPanel.tsx b/components/wysiwyg/PropsPanel.tsx index 8581080..8b8991d 100644 --- a/components/wysiwyg/PropsPanel.tsx +++ b/components/wysiwyg/PropsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import type { WObject } from '@/lib/wysiwyg/schema' export type PropsPanelProps = { @@ -15,9 +15,21 @@ function isImage(o: WObject): o is Extract { return o.type === 'image' } +function isText(o: WObject): o is Extract { + return o.type === 'text' +} + +function isButton(o: WObject): o is Extract { + return o.type === 'button' +} + export function PropsPanel({ object, onChange }: PropsPanelProps) { const [model, setModel] = useState(object) + useEffect(() => { + setModel(object) + }, [object]) + const emit = (next: WObject) => { setModel(next) onChange?.(next) @@ -32,6 +44,16 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) { const next: WObject = { ...model, props: { ...model.props, ...patch } } emit(next) } + const updateTextProps = (patch: Partial<{ text: string }>): void => { + if (!isText(model)) return + const next: WObject = { ...model, props: { ...model.props, ...patch } } + emit(next) + } + const updateButtonProps = (patch: Partial<{ label: string }>): void => { + if (!isButton(model)) return + const next: WObject = { ...model, props: { ...model.props, ...patch } } + emit(next) + } return (
@@ -58,6 +80,24 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
+ {isText(model) && ( +
+ +
+ )} + + {isButton(model) && ( +
+ +
+ )} + {isImage(model) && (
Start<\/a>/) + expect(out.css).toMatch(/#cta\s*{[^}]*width:120px;[^}]*height:36px/) + }) + + it('skips invisible layers when exporting html/css', () => { + const frame: Frame = { + ...makeFrame(), + layers: [ + { id: 'hidden', name: 'Hidden', visible: false, locked: false, objects: [makeFrame().layers[0].objects[0]] }, + ], + } + const out = exportFrame(frame) + expect(out.html).not.toContain('id="o1"') + expect(out.css).not.toContain('#o1') + }) }) diff --git a/lib/wysiwyg/export.ts b/lib/wysiwyg/export.ts index c6d0deb..41ee163 100644 --- a/lib/wysiwyg/export.ts +++ b/lib/wysiwyg/export.ts @@ -38,11 +38,26 @@ export function exportFrame(frame: Frame): Exported { piecesCss.push(r.css) } } - const html = `
${piecesHtml.join('')}
` + const inner = `
${piecesHtml.join('')}
` const css = [ `.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`, `.frame-scale{transform-origin:0 0}`, ...piecesCss, ].join('\n') + const html = [ + '', + '', + '', + '', + '', + 'WYSIWYG Frame', + '', + '', + '', + inner, + '', + '', + '', + ].join('') return { html, css, js: '' } }