From 1737ae172e60adb3851fa62a2bb22707a6ab1be0 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 21:23:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(wysiwyg):=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20nudge/=EB=A7=88=EC=9A=B0=EC=8A=A4?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=20+=208px=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=20(M1=201=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frame/Layer/Object 스키마(zod) 및 테스트 - 키보드 화살표 nudge 시 8px 스냅 적용 - 마우스 드래그 이동 시 8px 스냅 적용 - Canvas 컴포넌트 초기 버전 및 테스트 추가 --- MAIN_PLAN.md | 34 +++++++++ components/wysiwyg/Canvas.drag.test.tsx | 41 +++++++++++ components/wysiwyg/Canvas.nudge.test.tsx | 44 ++++++++++++ components/wysiwyg/Canvas.tsx | 91 ++++++++++++++++++++++++ lib/wysiwyg/schema.test.ts | 71 ++++++++++++++++++ lib/wysiwyg/schema.ts | 60 ++++++++++++++++ 6 files changed, 341 insertions(+) create mode 100644 components/wysiwyg/Canvas.drag.test.tsx create mode 100644 components/wysiwyg/Canvas.nudge.test.tsx create mode 100644 components/wysiwyg/Canvas.tsx create mode 100644 lib/wysiwyg/schema.test.ts create mode 100644 lib/wysiwyg/schema.ts diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index d137ecb..3c963f7 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -423,3 +423,37 @@ ### 커밋/푸쉬 원칙 - 기능 단위 커밋은 한국어 메시지 - 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행) + +## WYSIWYG 캔버스(드림위버 스타일) 계획 + +### 목표 +- 컨텐츠 영역에서 오브젝트를 자유 배치(드래그/리사이즈/회전)하여 랜딩을 구성 +- 웹표준/접근성/모바일·PC 반응형을 모두 충족하는 Export 산출 + +### 핵심 설계 원칙 +- 의미론적 HTML Export: Text→h1~h6/p, Button→a/button, Image→img/picture +- 접근성 + - 레이어(탭) 순서 ↔ DOM 순서 매핑, 필요 시 탭 순서 수동 설정 + - 이미지 alt/버튼 aria-label 필수, 헤딩 레벨 강제, 키보드 포커스/스킵링크 유지 + - 색 대비/타겟 크기 가이드 및 경고 +- 반응형 + - 1차: 고정 비율 스케일(MVP) + - 2차: 브레이크포인트 재배치(선택 그룹) + - 3차: 유동 단위/컨테이너 쿼리, clamp 폰트 확장 +- 품질 가드: 8px 격자 + 가장자리/중심 스냅, 오버랩/가독성 경고, 역할/속성 프리셋 + +### 기술/구현 전략 +- 모델: Frame/Layer/Object(Text/Image/Button) + 의미/접근성 필드(zod 스키마) +- 편집: Moveable로 드래그/리사이즈/회전 + 스냅/가이드/키보드 nudge +- Export: 오브젝트→의미론 태그 매핑, 반응형(스케일→브레이크포인트), 이미지 최적화 옵션 반영 + +### 마일스톤 +- M1: 캔버스 조작(드/리/회+스냅) TDD → 최소 구현 +- M2: 레이어/속성 패널 TDD → UI 연결(순서/잠금/숨김, 위치/크기/회전/폰트/색/링크/이미지) +- M3: Export(의미론+스케일) TDD → ZIP 생성 연동 +- M4: a11y/반응형 회귀 스모크 확대 + 품질 가드/경고 + +### 진행 현황 +- 브랜치: feat/wysiwyg-canvas 생성 +- 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test) +- 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge) diff --git a/components/wysiwyg/Canvas.drag.test.tsx b/components/wysiwyg/Canvas.drag.test.tsx new file mode 100644 index 0000000..509f1f5 --- /dev/null +++ b/components/wysiwyg/Canvas.drag.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas mouse drag with 8px snap', () => { + it('dragging object snaps to nearest 8px', () => { + const frame = makeFrame() + const { getByTestId } = render() + const canvas = getByTestId('wysiwyg-canvas') + const obj = getByTestId('obj-o1') + + // start drag + fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 }) + // move by +5,+5 (should snap from 101->104, 100->104) + fireEvent.mouseMove(canvas, { clientX: 205, clientY: 155 }) + fireEvent.mouseUp(canvas) + + expect(obj).toHaveStyle({ left: '104px', top: '104px' }) + }) +}) diff --git a/components/wysiwyg/Canvas.nudge.test.tsx b/components/wysiwyg/Canvas.nudge.test.tsx new file mode 100644 index 0000000..4d91bee --- /dev/null +++ b/components/wysiwyg/Canvas.nudge.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => { + it('ArrowRight nudges and snaps x to nearest 8px grid', () => { + const frame = makeFrame() + const { getByTestId } = render() + const canvas = getByTestId('wysiwyg-canvas') + fireEvent.keyDown(canvas, { key: 'ArrowRight' }) + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ left: '104px' }) // 101 + 1 => 102 -> snap8 => 104 + }) + + it('ArrowUp nudges and snaps y to nearest 8px grid', () => { + const frame = makeFrame() + const { getByTestId } = render() + const canvas = getByTestId('wysiwyg-canvas') + fireEvent.keyDown(canvas, { key: 'ArrowUp' }) + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96 + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx new file mode 100644 index 0000000..51c5f6f --- /dev/null +++ b/components/wysiwyg/Canvas.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import type { Frame, WObject } from '@/lib/wysiwyg/schema' + +function snap8(v: number) { + return Math.round(v / 8) * 8 +} + +export type CanvasProps = { + frame: Frame + onChange?: (next: Frame) => void +} + +export function Canvas({ frame, onChange }: CanvasProps) { + const [model, setModel] = useState(frame) + const [selectedId, setSelectedId] = useState(model.layers[0]?.objects[0]?.id ?? null) + const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null) + + const selected = useMemo(() => { + for (const layer of model.layers) { + const obj = layer.objects.find((o) => o.id === selectedId) + if (obj) return obj + } + return null + }, [model, selectedId]) + + const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => { + setModel((prev) => { + const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) } + onChange?.(next) + return next + }) + }, [onChange]) + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!selected) return + let dx = 0, dy = 0 + if (e.key === 'ArrowLeft') dx = -1 + else if (e.key === 'ArrowRight') dx = 1 + else if (e.key === 'ArrowUp') dy = -1 + else if (e.key === 'ArrowDown') dy = 1 + else return + e.preventDefault() + updateObject(selected.id, (o) => { + const nx = snap8(o.x + dx) + const ny = snap8(o.y + dy) + return { ...o, x: nx, y: ny } + }) + }, [selected, updateObject]) + + const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => { + setSelectedId(o.id) + draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y } + }, []) + + const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { + const d = draggingRef.current + if (!d) return + const dx = e.clientX - d.startX + const dy = e.clientY - d.startY + const nx = snap8(d.origX + dx) + const ny = snap8(d.origY + dy) + updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) + }, [updateObject]) + + const onMouseUpCanvas = useCallback(() => { + draggingRef.current = null + }, []) + + return ( +
+ {model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => ( +
onMouseDownObj(e, o)} + style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedId===o.id? '2px solid #0ea5e9':'none' }} + /> + )))} +
+ ) +} diff --git a/lib/wysiwyg/schema.test.ts b/lib/wysiwyg/schema.test.ts new file mode 100644 index 0000000..e5ef7e9 --- /dev/null +++ b/lib/wysiwyg/schema.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { frameSchema, objectSchema, layerSchema, type Frame, type WObject } from '@/lib/wysiwyg/schema' + +function validText(id: string): WObject { + return { + id, + type: 'text', + x: 100, + y: 120, + width: 300, + height: 40, + rotate: 0, + zIndex: 1, + props: { text: 'Hello', fontSize: 18, color: '#111111' }, + } +} + +function validImage(id: string): WObject { + return { + id, + type: 'image', + x: 50, + y: 60, + width: 240, + height: 180, + rotate: 0, + zIndex: 0, + props: { src: 'https://example.com/a.jpg', alt: 'A' }, + } +} + +describe('wysiwyg.schema', () => { + it('validates a Frame with layers and objects', () => { + const frame: Frame = { + id: 'frame-1', + width: 1200, + height: 800, + background: '#ffffff', + layers: [ + { id: 'layer-1', name: 'Base', visible: true, locked: false, objects: [validImage('img-1')] }, + { id: 'layer-2', name: 'Text', visible: true, locked: false, objects: [validText('txt-1')] }, + ], + } + const parsed = frameSchema.parse(frame) + expect(parsed.layers.length).toBe(2) + expect(parsed.layers[1].objects[0].type).toBe('text') + }) + + it('rejects invalid color and negative sizes', () => { + const bad: Frame = { + id: 'f', width: -10, height: 0, background: 'blue', layers: [], + } as unknown as Frame + expect(() => frameSchema.parse(bad)).toThrow() + }) + + it('objectSchema enforces type-specific props', () => { + const ok = objectSchema.parse(validText('t1')) + expect(ok.props.text).toBe('Hello') + + const bad = { ...validText('t2'), props: { src: 'x' } } + expect(() => objectSchema.parse(bad as unknown as WObject)).toThrow() + }) + + it('layerSchema enforces ordering integers and object bounds', () => { + const good = layerSchema.parse({ id: 'l1', name: 'L', visible: true, locked: false, objects: [validImage('i1')] }) + expect(good.objects[0].zIndex).toBe(0) + + const badObj = { ...validImage('i2'), width: -1 } + expect(() => objectSchema.parse(badObj as unknown as WObject)).toThrow() + }) +}) diff --git a/lib/wysiwyg/schema.ts b/lib/wysiwyg/schema.ts new file mode 100644 index 0000000..c984590 --- /dev/null +++ b/lib/wysiwyg/schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +const colorHex = z.string().regex(/^#([0-9a-fA-F]{3}){1,2}$/) + +const baseObject = z.object({ + id: z.string().min(1), + x: z.number(), + y: z.number(), + width: z.number().positive(), + height: z.number().positive(), + rotate: z.number(), + zIndex: z.number().int().nonnegative(), +}) + +const textObject = baseObject.extend({ + type: z.literal('text'), + props: z.object({ + text: z.string(), + fontSize: z.number().positive(), + color: colorHex, + }), +}) + +const imageObject = baseObject.extend({ + type: z.literal('image'), + props: z.object({ + src: z.string().min(1), + alt: z.string().optional().default(''), + }), +}) + +const buttonObject = baseObject.extend({ + type: z.literal('button'), + props: z.object({ + label: z.string().min(1), + href: z.string().optional().default(''), + color: colorHex.optional(), + background: colorHex.optional(), + }), +}) + +export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject]) +export type WObject = z.infer + +export const layerSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + visible: z.boolean(), + locked: z.boolean(), + objects: z.array(objectSchema), +}) + +export const frameSchema = z.object({ + id: z.string().min(1), + width: z.number().positive(), + height: z.number().positive(), + background: colorHex, + layers: z.array(layerSchema), +}) +export type Frame = z.infer