From 1737ae172e60adb3851fa62a2bb22707a6ab1be0 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 21:23:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(wysiwyg):=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=ED=82=A4=EB=B3=B4=EB=93=9C=20nudge/=EB=A7=88?= =?UTF-8?q?=EC=9A=B0=EC=8A=A4=20=EB=93=9C=EB=9E=98=EA=B7=B8=20+=208px=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=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 -- 2.52.0 From 98d33fc0d0b619ddede942cf9b00ffa7a6c5d0b9 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 21:35:28 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(wysiwyg):=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4/=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90=20=EB=B0=8F?= =?UTF-8?q?=20Export(=EC=A0=88=EB=8C=80=EB=B0=B0=EC=B9=98)=201=EC=B0=A8=20?= =?UTF-8?q?TDD/=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LayersPanel: 순서 변경/잠금/숨김 토글 - PropsPanel: 위치/크기/회전 및 이미지 src/alt 편집 - exportFrame: frame-scale 토큰 및 오브젝트 절대배치 HTML/CSS 출력 - 테스트 추가 및 전체 그린 유지 --- components/wysiwyg/Canvas.resize.test.tsx | 42 +++++++++++++ components/wysiwyg/Canvas.rotate.test.tsx | 57 +++++++++++++++++ components/wysiwyg/Canvas.tsx | 63 ++++++++++++++++--- components/wysiwyg/LayersPanel.test.tsx | 41 +++++++++++++ components/wysiwyg/LayersPanel.tsx | 65 ++++++++++++++++++++ components/wysiwyg/PropsPanel.test.tsx | 32 ++++++++++ components/wysiwyg/PropsPanel.tsx | 75 +++++++++++++++++++++++ lib/wysiwyg/export.test.ts | 42 +++++++++++++ lib/wysiwyg/export.ts | 48 +++++++++++++++ 9 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 components/wysiwyg/Canvas.resize.test.tsx create mode 100644 components/wysiwyg/Canvas.rotate.test.tsx create mode 100644 components/wysiwyg/LayersPanel.test.tsx create mode 100644 components/wysiwyg/LayersPanel.tsx create mode 100644 components/wysiwyg/PropsPanel.test.tsx create mode 100644 components/wysiwyg/PropsPanel.tsx create mode 100644 lib/wysiwyg/export.test.ts create mode 100644 lib/wysiwyg/export.ts diff --git a/components/wysiwyg/Canvas.resize.test.tsx b/components/wysiwyg/Canvas.resize.test.tsx new file mode 100644 index 0000000..5a89236 --- /dev/null +++ b/components/wysiwyg/Canvas.resize.test.tsx @@ -0,0 +1,42 @@ +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: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas resize with 8px snap', () => { + it('dragging bottom-right handle resizes and snaps to nearest 8px', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('resize-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + // start resize + fireEvent.mouseDown(handle, { clientX: 150, clientY: 140 }) + // move by +7,+9 → width 50-> 57 -> snap8 => 56, height 40->49 -> snap8 => 48 + fireEvent.mouseMove(canvas, { clientX: 157, clientY: 149 }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ width: '56px', height: '48px' }) + }) +}) diff --git a/components/wysiwyg/Canvas.rotate.test.tsx b/components/wysiwyg/Canvas.rotate.test.tsx new file mode 100644 index 0000000..3acd4bc --- /dev/null +++ b/components/wysiwyg/Canvas.rotate.test.tsx @@ -0,0 +1,57 @@ +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: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas rotate with snap', () => { + it('rotates with 15deg snap by default', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('rotate-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + // start rotate at 0deg + fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 }) + // move +16px on x → +16deg → snap15 => 15deg + fireEvent.mouseMove(canvas, { clientX: 116, clientY: 100 }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ transform: 'rotate(15deg)' }) + }) + + it('rotates with 1deg step while holding Shift', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('rotate-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 }) + // move +7px with Shift → +7deg, no snap + fireEvent.mouseMove(canvas, { clientX: 107, clientY: 100, shiftKey: true }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ transform: 'rotate(7deg)' }) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 51c5f6f..4a7741b 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -14,6 +14,8 @@ 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 resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null) + const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const selected = useMemo(() => { for (const layer of model.layers) { @@ -53,17 +55,49 @@ export function Canvas({ frame, onChange }: CanvasProps) { }, []) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { + const rot = rotatingRef.current + if (rot) { + const dx = e.clientX - rot.startX + let next = rot.origDeg + dx + if (!e.shiftKey) { + next = Math.round(next / 15) * 15 + } + updateObject(rot.id, (o) => ({ ...o, rotate: next })) + return + } + const r = resizingRef.current + 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)) + updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) + return + } 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 })) + if (d) { + 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 + resizingRef.current = null + rotatingRef.current = null + }, []) + + const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { + e.stopPropagation() + resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height } + }, []) + + const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => { + e.stopPropagation() + rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate } }, []) return ( @@ -84,7 +118,22 @@ export function Canvas({ frame, onChange }: CanvasProps) { aria-label={`object ${o.id}`} onMouseDown={(e) => 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' }} - /> + > + {selectedId === o.id && ( +
onMouseDownResize(e, o)} + style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }} + /> + )} + {selectedId === o.id && ( +
onMouseDownRotate(e, o)} + style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }} + /> + )} +
)))}
) diff --git a/components/wysiwyg/LayersPanel.test.tsx b/components/wysiwyg/LayersPanel.test.tsx new file mode 100644 index 0000000..84d6964 --- /dev/null +++ b/components/wysiwyg/LayersPanel.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { LayersPanel } from '@/components/wysiwyg/LayersPanel' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', width: 400, height: 300, background: '#ffffff', + layers: [ + { id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }, + { id: 'l2', name: 'Text', visible: true, locked: false, objects: [] }, + ], + } +} + +describe('LayersPanel', () => { + it('reorders layers with up/down buttons', () => { + const frame = makeFrame() + const onChange = vi.fn() + const { getByTestId, getAllByTestId } = render() + const items = getAllByTestId('layer-item') + expect(items[0]).toHaveTextContent('Base') + expect(items[1]).toHaveTextContent('Text') + + fireEvent.click(getByTestId('layer-down-l1')) + expect(onChange).toHaveBeenCalled() + const next = (onChange.mock.lastCall?.[0]) as Frame + expect(next.layers[0].id).toBe('l2') + expect(next.layers[1].id).toBe('l1') + }) + + it('toggles lock and visibility', () => { + const frame = makeFrame() + const { getByTestId } = render( {}} />) + fireEvent.click(getByTestId('layer-lock-l2')) + fireEvent.click(getByTestId('layer-hide-l2')) + // no crash; states toggled reflected in aria-pressed + expect(getByTestId('layer-lock-l2')).toHaveAttribute('aria-pressed', 'true') + expect(getByTestId('layer-hide-l2')).toHaveAttribute('aria-pressed', 'true') + }) +}) diff --git a/components/wysiwyg/LayersPanel.tsx b/components/wysiwyg/LayersPanel.tsx new file mode 100644 index 0000000..e0122b6 --- /dev/null +++ b/components/wysiwyg/LayersPanel.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useState } from 'react' +import type { Frame } from '@/lib/wysiwyg/schema' + +export type LayersPanelProps = { + frame: Frame + onChange?: (next: Frame) => void +} + +export function LayersPanel({ frame, onChange }: LayersPanelProps) { + const [model, setModel] = useState(frame) + + const move = useCallback((id: string, dir: 1 | -1) => { + const idx = model.layers.findIndex((l) => l.id === id) + if (idx < 0) return + const j = idx + dir + if (j < 0 || j >= model.layers.length) return + const nextLayers = model.layers.slice() + const [it] = nextLayers.splice(idx, 1) + nextLayers.splice(j, 0, it) + const next = { ...model, layers: nextLayers } + setModel(next) + onChange?.(next) + }, [model, onChange]) + + const toggle = useCallback((id: string, key: 'locked' | 'visible') => { + const nextLayers = model.layers.map((l) => l.id === id ? { ...l, [key]: key === 'visible' ? !l.visible : !l.locked } : l) + const next = { ...model, layers: nextLayers } + setModel(next) + onChange?.(next) + }, [model, onChange]) + + return ( +
+ {model.layers.map((l) => ( +
+ {l.name} + + + + +
+ ))} +
+ ) +} diff --git a/components/wysiwyg/PropsPanel.test.tsx b/components/wysiwyg/PropsPanel.test.tsx new file mode 100644 index 0000000..3336734 --- /dev/null +++ b/components/wysiwyg/PropsPanel.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { PropsPanel } from '@/components/wysiwyg/PropsPanel' +import type { WObject } from '@/lib/wysiwyg/schema' + +describe('PropsPanel', () => { + it('edits position/size/rotate numeric fields', () => { + const obj: WObject = { id: 'o1', type: 'image', x: 10, y: 20, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } } + const calls: WObject[] = [] + const { getByLabelText } = render( calls.push(n)} />) + fireEvent.change(getByLabelText('X'), { target: { value: '12' } }) + fireEvent.change(getByLabelText('Y'), { target: { value: '30' } }) + fireEvent.change(getByLabelText('Width'), { target: { value: '120' } }) + fireEvent.change(getByLabelText('Height'), { target: { value: '88' } }) + fireEvent.change(getByLabelText('Rotate'), { target: { value: '15' } }) + const last = calls.at(-1)! + expect(last.x).toBe(12) + expect(last.y).toBe(30) + expect(last.width).toBe(120) + expect(last.height).toBe(88) + expect(last.rotate).toBe(15) + }) + + it('edits image src/alt', () => { + const obj: WObject = { id: 'o2', type: 'image', x: 0, y: 0, width: 10, height: 10, rotate: 0, zIndex: 0, props: { src: 'x', alt: 'a' } } + const calls: WObject[] = [] + const { getByLabelText } = render( calls.push(n)} />) + fireEvent.change(getByLabelText('Src'), { target: { value: 'https://example.com/img.jpg' } }) + fireEvent.change(getByLabelText('Alt'), { target: { value: 'Example' } }) + expect(calls.at(-1)?.props).toEqual({ src: 'https://example.com/img.jpg', alt: 'Example' }) + }) +}) diff --git a/components/wysiwyg/PropsPanel.tsx b/components/wysiwyg/PropsPanel.tsx new file mode 100644 index 0000000..8581080 --- /dev/null +++ b/components/wysiwyg/PropsPanel.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react' +import type { WObject } from '@/lib/wysiwyg/schema' + +export type PropsPanelProps = { + object: WObject + onChange?: (next: WObject) => void +} + +function num(v: string, fallback: number) { + const n = Number(v) + return Number.isFinite(n) ? n : fallback +} + +function isImage(o: WObject): o is Extract { + return o.type === 'image' +} + +export function PropsPanel({ object, onChange }: PropsPanelProps) { + const [model, setModel] = useState(object) + + const emit = (next: WObject) => { + setModel(next) + onChange?.(next) + } + + const update = (patch: Partial>): void => { + const next: WObject = { ...model, ...patch } + emit(next) + } + const updateImageProps = (patch: Partial<{ src: string; alt?: string }>): void => { + if (!isImage(model)) return + const next: WObject = { ...model, props: { ...model.props, ...patch } } + emit(next) + } + + return ( +
+
+ + + + + +
+ + {isImage(model) && ( +
+ + +
+ )} +
+ ) +} diff --git a/lib/wysiwyg/export.test.ts b/lib/wysiwyg/export.test.ts new file mode 100644 index 0000000..fb0e5f7 --- /dev/null +++ b/lib/wysiwyg/export.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { exportFrame } from '@/lib/wysiwyg/export' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', width: 320, height: 200, background: '#ffffff', + layers: [ + { id: 'l1', name: 'Base', visible: true, locked: false, objects: [ + { id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } }, + ]}, + { id: 'l2', name: 'Text', visible: true, locked: false, objects: [ + { id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } }, + ]}, + ], + } +} + +describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => { + it('renders wrapper with fixed width/height and absolutely positioned children', () => { + const frame = makeFrame() + const out = exportFrame(frame) + expect(out.html).toMatch(/
]*src=\"https:\/\/example.com\/a.jpg\"/) + expect(out.css).toMatch(/#o1\s*{[^}]*left:16px;[^}]*top:24px;[^}]*width:80px;[^}]*height:60px;[^}]*transform:rotate\(15deg\)/) + // text object + expect(out.html).toMatch(/

{ + const frame = makeFrame() + const out = exportFrame(frame) + expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/) + expect(out.html).toMatch(/class=\"frame-scale\"/) + }) +}) diff --git a/lib/wysiwyg/export.ts b/lib/wysiwyg/export.ts new file mode 100644 index 0000000..c6d0deb --- /dev/null +++ b/lib/wysiwyg/export.ts @@ -0,0 +1,48 @@ +import type { Frame, WObject } from '@/lib/wysiwyg/schema' + +export type Exported = { html: string; css: string; js: string } + +function esc(s: string) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +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' } + +function renderObject(o: WObject): { html: string; css: string } { + const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}` + if (isImage(o)) { + const html = `${esc(o.props.alt || '')}` + return { html, css: baseCss } + } + if (isText(o)) { + const html = `

${esc(o.props.text)}

` + return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` } + } + // button minimal + const label = isButton(o) ? o.props.label : 'Button' + const href = isButton(o) ? (o.props.href || '#') : '#' + const html = `${esc(label)}` + return { html, css: baseCss } +} + +export function exportFrame(frame: Frame): Exported { + const piecesHtml: string[] = [] + const piecesCss: string[] = [] + for (const layer of frame.layers) { + if (!layer.visible) continue + for (const o of layer.objects) { + const r = renderObject(o) + piecesHtml.push(r.html) + piecesCss.push(r.css) + } + } + const html = `
${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') + return { html, css, js: '' } +} -- 2.52.0 From 66cb36cd9d5edc1f68358d1438ee455f545fb04e Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 22:17:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?WYSIWYG:=20=EC=8A=A4=EB=83=85=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EA=B3=B5=ED=86=B5=ED=99=94(snap8/snapAngle)=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=9D=B8=ED=94=8C=EB=9E=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 10 ++++++++++ lib/wysiwyg/snap.test.ts | 24 ++++++++++++++++++++++++ lib/wysiwyg/snap.ts | 16 ++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 lib/wysiwyg/snap.test.ts create mode 100644 lib/wysiwyg/snap.ts diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 3c963f7..9a15312 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -457,3 +457,13 @@ - 브랜치: feat/wysiwyg-canvas 생성 - 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test) - 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge) + - 스냅 유틸 공통화 완료: `lib/wysiwyg/snap.ts` (`snap8`, `snapAngle`) + 테스트 `lib/wysiwyg/snap.test.ts` + - 정책: 위치 8px 스냅, 회전 15° 스냅(Shift시 1° 미세제어) + - 경계 보정: 0<|deg|<15 구간은 15°/−15°로 스냅되도록 조정 + +### 다음 작업(캔버스 Moveable 통합) +- 패키지 추가: `react-moveable`, `moveable` +- TDD: Moveable 기반 드래그/리사이즈/회전 일원화 테스트 + 8px/15° 스냅 및 가이드라인 표시/해제 검증 +- 구현: `components/wysiwyg/Canvas.tsx`에 Moveable 적용, 기존 조작 로직 치환, 공통 스냅 유틸 사용 +- 회귀: 기존 키보드 nudge/가이드라인 테스트 유지 그린 + diff --git a/lib/wysiwyg/snap.test.ts b/lib/wysiwyg/snap.test.ts new file mode 100644 index 0000000..98c4c17 --- /dev/null +++ b/lib/wysiwyg/snap.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { snap8, snapAngle } from '@/lib/wysiwyg/snap' + +describe('snap utils', () => { + it('snap8 rounds to nearest 8', () => { + expect(snap8(0)).toBe(0) + expect(snap8(3)).toBe(0) + expect(snap8(4)).toBe(8) + expect(snap8(11)).toBe(8) + expect(snap8(12)).toBe(16) + }) + + it('snapAngle snaps to 15deg by default', () => { + expect(snapAngle(0)).toBe(0) + expect(snapAngle(7)).toBe(15) + expect(snapAngle(22)).toBe(15) + expect(snapAngle(30)).toBe(30) + }) + + it('snapAngle respects fine=true (1deg)', () => { + expect(snapAngle(7, true)).toBe(7) + expect(snapAngle(22, true)).toBe(22) + }) +}) diff --git a/lib/wysiwyg/snap.ts b/lib/wysiwyg/snap.ts new file mode 100644 index 0000000..40381e4 --- /dev/null +++ b/lib/wysiwyg/snap.ts @@ -0,0 +1,16 @@ +// 8px 격자 스냅: 주어진 값 v를 가장 가까운 8의 배수로 반올림 +export function snap8(v: number): number { + return Math.round(v / 8) * 8 +} + +export function snapAngle(deg: number, fine: boolean = false): number { + // fine=true이면 미세 제어(Shift)로 1° 단위 스냅을 그대로 허용 + if (fine) return deg + // 0도는 그대로 유지 + if (deg === 0) return 0 + // 첫 구간 경계 보정: 0<|deg|<15는 15°/−15°로 스냅(사용자 체감상 0도로 붙지 않게) + if (deg > 0 && deg < 15) return 15 + if (deg < 0 && deg > -15) return -15 + // 기본은 15° 단위로 가장 가까운 각도로 반올림 + return Math.round(deg / 15) * 15 +} -- 2.52.0 From ccb706de5b90da1cdcc53a4182819888b69ccd6b Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 22:22:23 +0900 Subject: [PATCH 4/5] =?UTF-8?q?WYSIWYG:=20Moveable=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9(=EB=93=9C/=EB=A6=AC/=ED=9A=8C=20props=20+=20?= =?UTF-8?q?8px=20=EC=8A=A4=EB=83=85=20=EA=B7=B8=EB=A6=AC=EB=93=9C)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/wysiwyg/Canvas.moveable.test.tsx | 56 +++++ components/wysiwyg/Canvas.tsx | 22 ++ package-lock.json | 251 ++++++++++++++++++++ package.json | 2 + 4 files changed, 331 insertions(+) create mode 100644 components/wysiwyg/Canvas.moveable.test.tsx diff --git a/components/wysiwyg/Canvas.moveable.test.tsx b/components/wysiwyg/Canvas.moveable.test.tsx new file mode 100644 index 0000000..e76d246 --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +vi.mock('react-moveable', () => { + const Mock = (props: Record) =>
+ return { default: Mock } +}) + +const frame: Frame = { + id: 'frame1', + width: 800, + height: 600, + background: '#ffffff', + layers: [ + { + id: 'layer1', + name: 'Layer 1', + visible: true, + locked: false, + objects: [ + { id: 'obj1', type: 'text', x: 10, y: 12, width: 100, height: 40, rotate: 0, zIndex: 0, props: { text: 'Hello', fontSize: 16, color: '#000000' } }, + ], + }, + ], +} + +describe('Canvas + Moveable integration (smoke)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders Moveable with draggable/resizable/rotatable and 8px grid', () => { + render() + + const mv = screen.getByTestId('moveable') + expect(mv).toBeInTheDocument() + + const props = JSON.parse(mv.getAttribute('data-props') || '{}') + expect(props.draggable).toBe(true) + expect(props.resizable).toBe(true) + expect(props.rotatable).toBe(true) + expect(props.snappable).toBe(true) + expect(props.snapGridWidth).toBe(8) + expect(props.snapGridHeight).toBe(8) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 4a7741b..6c3ec42 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' +import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' function snap8(v: number) { @@ -16,6 +17,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { 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 rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) + const [showGuides, setShowGuides] = useState(false) + const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const selected = useMemo(() => { for (const layer of model.layers) { @@ -52,6 +55,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { 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 } + setShowGuides(true) + setGuidePos({ x: snap8(o.x), y: snap8(o.y) }) }, []) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { @@ -80,6 +85,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { const dy = e.clientY - d.startY const nx = snap8(d.origX + dx) const ny = snap8(d.origY + dy) + setGuidePos({ x: nx, y: ny }) updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) } }, [updateObject]) @@ -88,6 +94,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { draggingRef.current = null resizingRef.current = null rotatingRef.current = null + setShowGuides(false) }, []) const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { @@ -111,6 +118,12 @@ export function Canvas({ frame, onChange }: CanvasProps) { style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }} data-testid="wysiwyg-canvas" > + {showGuides && ( + <> +
+
+ + )} {model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
)))} + {/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */} +
) } diff --git a/package-lock.json b/package-lock.json index cc96122..c185243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "jszip": "^3.10.1", + "moveable": "^0.53.0", "next": "16.0.2", "next-intl": "^4.5.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-moveable": "^0.56.0", "zod": "^4.1.12", "zustand": "^5.0.8" }, @@ -385,6 +387,15 @@ "node": ">=18" } }, + "node_modules/@cfcs/core": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.6.tgz", + "integrity": "sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==", + "license": "MIT", + "dependencies": { + "@egjs/component": "^3.0.2" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -520,6 +531,12 @@ "node": ">=18" } }, + "node_modules/@daybrush/utils": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@daybrush/utils/-/utils-1.13.0.tgz", + "integrity": "sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -587,6 +604,33 @@ "react": ">=16.8.0" } }, + "node_modules/@egjs/agent": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@egjs/agent/-/agent-2.4.4.tgz", + "integrity": "sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==", + "license": "MIT" + }, + "node_modules/@egjs/children-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz", + "integrity": "sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==", + "license": "MIT", + "dependencies": { + "@egjs/list-differ": "^1.0.0" + } + }, + "node_modules/@egjs/component": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.5.tgz", + "integrity": "sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==", + "license": "MIT" + }, + "node_modules/@egjs/list-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/list-differ/-/list-differ-1.0.1.tgz", + "integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", @@ -2449,6 +2493,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@scena/dragscroll": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scena/dragscroll/-/dragscroll-1.4.0.tgz", + "integrity": "sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.6.0", + "@scena/event-emitter": "^1.0.2" + } + }, + "node_modules/@scena/event-emitter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@scena/event-emitter/-/event-emitter-1.0.5.tgz", + "integrity": "sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.1.1" + } + }, + "node_modules/@scena/matrix": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scena/matrix/-/matrix-1.1.1.tgz", + "integrity": "sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.4.0" + } + }, "node_modules/@schummar/icu-type-parser": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", @@ -4563,6 +4635,52 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/croact": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/croact/-/croact-1.0.4.tgz", + "integrity": "sha512-9GhvyzTY/IVUrMQ2iz/mzgZ8+NcjczmIo/t4FkC1CU0CEcau6v6VsEih4jkTa4ZmRgYTF0qXEZLObCzdDFplpw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/list-differ": "^1.0.0" + } + }, + "node_modules/croact-css-styled": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/croact-css-styled/-/croact-css-styled-1.1.9.tgz", + "integrity": "sha512-G7yvRiVJ3Eoj0ov2h2xR4312hpOzATay2dGS9clK8yJQothjH1sBXIyvOeRP5wBKD9mPcKcoUXPCPsl0tQog4w==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "css-styled": "~1.0.8", + "framework-utils": "^1.1.0" + } + }, + "node_modules/croact-moveable": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/croact-moveable/-/croact-moveable-0.9.0.tgz", + "integrity": "sha512-fc3bieV6CdqqZFtzsSLi9KmvUMFW3oakUfhPCls1BxKjOfUfn8rktteGED2341A/Qghy8tI3Hm6SdocIc68IKg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/agent": "^2.2.1", + "@egjs/children-differ": "^1.0.1", + "@egjs/list-differ": "^1.0.0", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "@scena/matrix": "^1.1.1", + "croact-css-styled": "^1.1.9", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.3", + "overlap-area": "^1.1.0", + "react-css-styled": "^1.1.9", + "react-moveable": "~0.56.0" + }, + "peerDependencies": { + "croact": "^1.0.4" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4578,6 +4696,25 @@ "node": ">= 8" } }, + "node_modules/css-styled": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/css-styled/-/css-styled-1.0.8.tgz", + "integrity": "sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0" + } + }, + "node_modules/css-to-mat": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/css-to-mat/-/css-to-mat-1.1.1.tgz", + "integrity": "sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/matrix": "^1.0.0" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -5723,6 +5860,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framework-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/framework-utils/-/framework-utils-1.1.0.tgz", + "integrity": "sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5799,6 +5942,16 @@ "node": ">=6.9.0" } }, + "node_modules/gesto": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/gesto/-/gesto-1.19.4.tgz", + "integrity": "sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/event-emitter": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7207,6 +7360,24 @@ "setimmediate": "^1.0.5" } }, + "node_modules/keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==", + "license": "MIT" + }, + "node_modules/keycon": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/keycon/-/keycon-1.4.0.tgz", + "integrity": "sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==", + "license": "MIT", + "dependencies": { + "@cfcs/core": "^0.0.6", + "@daybrush/utils": "^1.7.1", + "@scena/event-emitter": "^1.0.2", + "keycode": "^2.2.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7703,6 +7874,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moveable": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/moveable/-/moveable-0.53.0.tgz", + "integrity": "sha512-71jS9zIoQzMhnNvduhg4tUEdm23+fO/40FN7muVMbZvVwbTku2MIxxLhnU4qFvxI4oVxn75l79SbtgjuA+s7Pw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/event-emitter": "^1.0.5", + "croact": "^1.0.4", + "croact-moveable": "~0.9.0", + "react-moveable": "~0.56.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8065,6 +8249,15 @@ "node": ">= 0.8.0" } }, + "node_modules/overlap-area": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz", + "integrity": "sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.7.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8392,6 +8585,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-css-styled": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-css-styled/-/react-css-styled-1.1.9.tgz", + "integrity": "sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==", + "license": "MIT", + "dependencies": { + "css-styled": "~1.0.8", + "framework-utils": "^1.1.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -8411,6 +8614,36 @@ "dev": true, "license": "MIT" }, + "node_modules/react-moveable": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/react-moveable/-/react-moveable-0.56.0.tgz", + "integrity": "sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/agent": "^2.2.1", + "@egjs/children-differ": "^1.0.1", + "@egjs/list-differ": "^1.0.0", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "@scena/matrix": "^1.1.1", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.3", + "overlap-area": "^1.1.0", + "react-css-styled": "^1.1.9", + "react-selecto": "^1.25.0" + } + }, + "node_modules/react-selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/react-selecto/-/react-selecto-1.26.3.tgz", + "integrity": "sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==", + "license": "MIT", + "dependencies": { + "selecto": "~1.26.3" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -8705,6 +8938,24 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/selecto/-/selecto-1.26.3.tgz", + "integrity": "sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/children-differ": "^1.0.1", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "css-styled": "^1.0.8", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.4", + "keycon": "^1.2.0", + "overlap-area": "^1.1.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/package.json b/package.json index f86dac9..e6e5106 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "jszip": "^3.10.1", + "moveable": "^0.53.0", "next": "16.0.2", "next-intl": "^4.5.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-moveable": "^0.56.0", "zod": "^4.1.12", "zustand": "^5.0.8" }, -- 2.52.0 From 1b1b916a306198d849f378ee48999792f5657a96 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 23:21:43 +0900 Subject: [PATCH 5/5] =?UTF-8?q?WYSIWYG:=20Moveable=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=9D=BC=EC=9D=B8=20=EA=B3=A0=EB=8F=84=ED=99=94(?= =?UTF-8?q?=EC=A4=91=EC=8B=AC/=EA=B0=80=EC=9E=A5=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85,=208px=20=EC=9E=84=EA=B3=84)=20TDD/=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 38 +++++ .../Canvas.guidelines.advanced.test.tsx | 68 +++++++++ .../wysiwyg/Canvas.guidelines.edges.test.tsx | 65 ++++++++ .../Canvas.guidelines.horizontal.test.tsx | 65 ++++++++ .../wysiwyg/Canvas.moveable.events.test.tsx | 95 ++++++++++++ .../wysiwyg/Canvas.moveable.target.test.tsx | 44 ++++++ components/wysiwyg/Canvas.tsx | 139 ++++++++++++++++++ 7 files changed, 514 insertions(+) create mode 100644 components/wysiwyg/Canvas.guidelines.advanced.test.tsx create mode 100644 components/wysiwyg/Canvas.guidelines.edges.test.tsx create mode 100644 components/wysiwyg/Canvas.guidelines.horizontal.test.tsx create mode 100644 components/wysiwyg/Canvas.moveable.events.test.tsx create mode 100644 components/wysiwyg/Canvas.moveable.target.test.tsx diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 9a15312..0900b9e 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -1,5 +1,34 @@ # 메인플랜: 랜딩 페이지 빌더 +### 가이드라인 고도화 진행(2025-11-16) +- 목표: 객체-객체 스냅 가이드(8px 임계) + - 수직 중심 정렬 가이드/스냅 + - 수평 중심 정렬 가이드/스냅 + - 좌/우/상/하 가장자리 정렬 가이드/스냅 +- TDD + - `components/wysiwyg/Canvas.guidelines.advanced.test.tsx` + - 수직 중심 스냅 가이드(8px) 검증. Moveable 모킹, `act`/`waitFor` 적용 + - `components/wysiwyg/Canvas.guidelines.horizontal.test.tsx` + - 수평 중심 스냅 가이드(8px) 검증 + - `components/wysiwyg/Canvas.guidelines.edges.test.tsx` + - 좌/우 에지 스냅 가이드(8px) 1차 케이스 검증(T→L 등 에지 페어) +- 구현 + - `components/wysiwyg/Canvas.tsx` + - onDrag: 그리드(8px) → 객체 중심 스냅(수직/수평) → 객체 에지 스냅(좌/우/상/하) 우선순위 적용 + - 가이드 좌표: 축별로 에지 > 중심 > 그리드 순서로 표시 + - 드래그 시작 시 가이드 표시, 종료 시 가이드 해제 유지 +- 결과: 전체 테스트 그린(131/131) + +#### 오류 원인/디버깅(메모) +- 실패: 수직 중심 스냅 테스트에서 선택 대상이 기본 선택되지 않아 좌표 미변경 → 레이어 objects 순서를 조정(o1을 첫 요소로) +- 실패: 비동기 상태 반영 전 단정 → Moveable 이벤트 호출을 `act`로 감싸고 결과 검증을 `waitFor`/`findByTestId`로 보강하여 해결 +- 실패: 가장자리 스냅 미구현으로 208px(그리드) 유지 → 에지 스냅(LL/LR/RL/RR, TT/TB/BT/BB) 페어 계산 로직과 8px 임계 우선순위 적용으로 해결 + +#### 다음 단계 +- 키보드 nudge(Shift/Alt 미세/가속)와 가이드 반영 TDD/구현 +- 다중 선택 및 그룹 이동/정렬 스펙 초안 TDD +- 접근성: 키보드 조작 시 라이브 리전 안내/가이드라인 ARIA 노출 정책 검토 + ## 목표 - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. @@ -246,6 +275,7 @@ - a11y/SEO/OG/Analytics ## 오류/버그 기록 +- [2025-11-16 22:32] jsdom 환경에서 Moveable 내부 `document.elementFromPoint` 미구현으로 예외 발생 → `Canvas.tsx`에 테스트 전용 폴리필 추가(`elementFromPoint: () => null`)로 해결. 린트 any 제거 위해 타입(`DocWithEFP`) 선언 및 미사용 인자 경고 최소화. - [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인 - [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지 - [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린 @@ -461,6 +491,14 @@ - 정책: 위치 8px 스냅, 회전 15° 스냅(Shift시 1° 미세제어) - 경계 보정: 0<|deg|<15 구간은 15°/−15°로 스냅되도록 조정 +- Moveable 통합 완료(TDD→구현→그린) + - 설치: `react-moveable`, `moveable` + - 스모크: Moveable가 드래그/리사이즈/회전 + 8px 스냅 그리드 props로 렌더 + - 이벤트 연동: onDrag/onResize/onRotate에서 `snap8`/`snapAngle` 적용, 가이드라인 표시/해제 + - Shift 1° 미세 제어 타입가드(`isShiftEvent`) 도입 + - target 바인딩: 선택 객체의 data-testid 셀렉터(`[data-testid="obj-"]`)를 Moveable `target`으로 전달 + + ### 다음 작업(캔버스 Moveable 통합) - 패키지 추가: `react-moveable`, `moveable` - TDD: Moveable 기반 드래그/리사이즈/회전 일원화 테스트 + 8px/15° 스냅 및 가이드라인 표시/해제 검증 diff --git a/components/wysiwyg/Canvas.guidelines.advanced.test.tsx b/components/wysiwyg/Canvas.guidelines.advanced.test.tsx new file mode 100644 index 0000000..d2b59e8 --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.advanced.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택이 되도록 첫 번째로 배치 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 대상(o2)의 수직 중심선은 x = 240 (x=200, w=80) + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (object-object snap)', () => { + beforeEach(() => capture.mockReset()) + + it('shows vertical guide and snaps x to other object center within 8px threshold', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + // 드래그 시작 → 가이드 활성화 상태 + await act(async () => { last.onDragStart?.() }) + + // o1의 중심을 o2 중심(240)에 근접시키도록 left를 220로 제안 + // o1 width=40이므로 중심은 left+20 → 240이 되도록 left=220 필요 + await act(async () => { last.onDrag?.({ left: 220, top: 10 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + // x 스냅 결과: left가 정확히 220으로 맞춰짐 + await waitFor(() => { + expect(obj.style.left).toBe('220px') + }) + // 가이드 라인 표시 + await screen.findByTestId('guide-x') + + // 드래그 종료 → 가이드 해제 + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.guidelines.edges.test.tsx b/components/wysiwyg/Canvas.guidelines.edges.test.tsx new file mode 100644 index 0000000..05c8dad --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.edges.test.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides-edges', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택되도록 첫 번째 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 기준(o2) - 좌/우 에지: 200, 280 + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (edge snap)', () => { + beforeEach(() => capture.mockReset()) + + it('snaps left edge to other object left edge within 8px threshold and shows vertical guide', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onDragStart?.() }) + + // o2.left = 200. o1을 205로 제안하면 임계(≤8px) 내 → 200으로 스냅 + await act(async () => { last.onDrag?.({ left: 205, top: 10 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.left).toBe('200px') + }) + + // 수직 가이드 라인 표시(에지 x=200) + await screen.findByTestId('guide-x') + + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx b/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx new file mode 100644 index 0000000..0c3c379 --- /dev/null +++ b/components/wysiwyg/Canvas.guidelines.horizontal.test.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +type MProps = { + onDragStart?: () => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: () => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-guides-h', + width: 500, + height: 400, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + // 드래그 대상(o1) - 기본 선택이 되도록 첫 번째 + { id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 40, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } }, + // 대상(o2) 수평 중심선: y = 65 (y=50, h=30) + { id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas guidelines advanced (horizontal center snap)', () => { + beforeEach(() => capture.mockReset()) + + it('shows horizontal guide and snaps y to other object center within 8px threshold', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onDragStart?.() }) + + // o1 height=40 → 중심은 top+20. o2 중심 65에 맞추려면 top=45 필요 + await act(async () => { last.onDrag?.({ left: 10, top: 45 }) }) + + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.top).toBe('45px') + }) + + // 수평 가이드 라인 표시 + await screen.findByTestId('guide-y') + + await act(async () => { last.onDragEnd?.() }) + await waitFor(() => { + expect(screen.queryByTestId('guide-y')).toBeNull() + }) + }) +}) diff --git a/components/wysiwyg/Canvas.moveable.events.test.tsx b/components/wysiwyg/Canvas.moveable.events.test.tsx new file mode 100644 index 0000000..b9a969b --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.events.test.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() +type MProps = { + onDragStart?: (e: unknown) => void + onDrag?: (e: { left?: number; top?: number }) => void + onDragEnd?: (e: unknown) => void + onResize?: (e: { width?: number; height?: number }) => void + onRotate?: (e: { rotate?: number; inputEvent?: { shiftKey?: boolean } }) => void +} + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f1', + width: 400, + height: 300, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + { id: 'o1', type: 'text', x: 10, y: 10, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'T', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas + Moveable events', () => { + beforeEach(() => { + capture.mockReset() + }) + + it('onDrag applies 8px snap and updates left/top, guides show/hide', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + // start drag: guides visible + await act(async () => { last.onDragStart?.({}) }) + await screen.findByTestId('guide-x') + + // move: set left/top to 17/21 → snapped to 16/24 + await act(async () => { last.onDrag?.({ left: 17, top: 21 }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.left).toBe('16px') + expect(obj.style.top).toBe('24px') + }) + + // end: guides hidden + await act(async () => { last.onDragEnd?.({}) }) + await waitFor(() => { + expect(screen.queryByTestId('guide-x')).toBeNull() + }) + }) + + it('onResize applies 8px snap and min size', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onResize?.({ width: 83, height: 13 }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.width).toBe('80px') + expect(obj.style.height).toBe('16px') + }) + }) + + it('onRotate applies 15deg snap (no shift) and 1deg with shift', async () => { + render() + const last = capture.mock.calls.at(-1)?.[0] as MProps + expect(last).toBeTruthy() + + await act(async () => { last.onRotate?.({ rotate: 7, inputEvent: { shiftKey: false } }) }) + const obj = screen.getByTestId('obj-o1') as HTMLElement + await waitFor(() => { + expect(obj.style.transform).toContain('rotate(15deg)') + }) + + await act(async () => { last.onRotate?.({ rotate: 22, inputEvent: { shiftKey: true } }) }) + await waitFor(() => { + expect(obj.style.transform).toContain('rotate(22deg)') + }) + }) +}) diff --git a/components/wysiwyg/Canvas.moveable.target.test.tsx b/components/wysiwyg/Canvas.moveable.target.test.tsx new file mode 100644 index 0000000..d475553 --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.target.test.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +const capture = vi.fn() + +vi.mock('react-moveable', () => { + const Mock = (props: Record) => { + capture(props) + return
+ } + return { default: Mock } +}) + +const frame: Frame = { + id: 'f-target', + width: 300, + height: 200, + background: '#fff', + layers: [ + { id: 'l1', name: 'L1', visible: true, locked: false, objects: [ + { id: 'o1', type: 'text', x: 8, y: 8, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#000' } }, + { id: 'o2', type: 'text', x: 96, y: 8, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#000' } }, + ]} + ] +} + +describe('Canvas + Moveable target binding', () => { + beforeEach(() => { + capture.mockReset() + }) + + it('passes the selected object selector as Moveable target', () => { + const { getByTestId } = render() + + // 기본 선택은 첫 객체(o1)라고 가정 + const el = getByTestId('obj-o1') + const last = capture.mock.calls.at(-1)?.[0] as { target?: string } + expect(last).toBeTruthy() + expect(last?.target).toBe('[data-testid="obj-o1"]') + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 6c3ec42..fd97b35 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -1,11 +1,26 @@ import React, { useCallback, 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) +} + +// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가 +type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null } +if (typeof document !== 'undefined') { + const d = document as DocWithEFP + if (typeof d.elementFromPoint !== 'function') { + d.elementFromPoint = (_x: number, _y: number) => null + } +} + export type CanvasProps = { frame: Frame onChange?: (next: Frame) => void @@ -19,6 +34,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const [showGuides, setShowGuides] = useState(false) const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + // 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환) + const selectedElRef = useRef(null) const selected = useMemo(() => { for (const layer of model.layers) { @@ -130,6 +147,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { data-testid={`obj-${o.id}`} 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: selectedId===o.id? '2px solid #0ea5e9':'none' }} > {selectedId === o.id && ( @@ -150,12 +168,133 @@ export function Canvas({ frame, onChange }: CanvasProps) { )))} {/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */} { + // 드래그 시작 시 가이드 표시 + setShowGuides(true) + }} + onDrag={({ left, top }) => { + if (!selected) return + const rawLeft = typeof left === 'number' ? left : 0 + const rawTop = typeof top === 'number' ? top : 0 + let nx = snap8(rawLeft) + let ny = snap8(rawTop) + + // 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px + const threshold = 8 + let snappedCenterX: number | undefined + let snappedCenterY: number | undefined + // 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표 + let edgeNx: number | undefined + let edgeNy: number | undefined + let guideEdgeX: number | undefined + let guideEdgeY: number | undefined + let minDistX = Number.POSITIVE_INFINITY + let minDistY = Number.POSITIVE_INFINITY + outer: for (const layer of model.layers) { + for (const obj of layer.objects) { + if (obj.id === selected.id) continue + const otherCenterX = obj.x + obj.width / 2 + const selCenterX = rawLeft + selected.width / 2 + if (Math.abs(selCenterX - otherCenterX) <= threshold) { + snappedCenterX = otherCenterX + // 수평 중심도 동시에 체크 + const otherCenterY = obj.y + obj.height / 2 + const selCenterY = rawTop + selected.height / 2 + if (Math.abs(selCenterY - otherCenterY) <= threshold) { + snappedCenterY = otherCenterY + } + break outer + } + const otherCenterY2 = obj.y + obj.height / 2 + const selCenterY2 = rawTop + selected.height / 2 + if (Math.abs(selCenterY2 - otherCenterY2) <= threshold) { + snappedCenterY = otherCenterY2 + // x는 그리드, y만 스냅으로 나가고 루프 종료 + break outer + } + + // 에지 스냅 후보 계산 (X축) + const selLeft = rawLeft + const selRight = rawLeft + selected.width + const otherLeft = obj.x + const otherRight = obj.x + obj.width + const xPairs: Array<{dist: number; nx: number; guideX: number}> = [ + { dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L + { dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R + { dist: Math.abs(selRight - otherLeft), nx: otherLeft - selected.width, guideX: otherLeft }, // R-L + { dist: Math.abs(selRight - otherRight), nx: otherRight - selected.width, guideX: otherRight }, // R-R + ] + for (const p of xPairs) { + if (p.dist <= threshold && p.dist < minDistX) { + minDistX = p.dist + edgeNx = p.nx + guideEdgeX = p.guideX + } + } + + // 에지 스냅 후보 계산 (Y축) + const selTop = rawTop + const selBottom = rawTop + selected.height + const otherTop = obj.y + const otherBottom = obj.y + obj.height + const yPairs: Array<{dist: number; ny: number; guideY: number}> = [ + { dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T + { dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B + { dist: Math.abs(selBottom - otherTop), ny: otherTop - selected.height, guideY: otherTop }, // B-T + { dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selected.height, guideY: otherBottom }, // B-B + ] + for (const p of yPairs) { + if (p.dist <= threshold && p.dist < minDistY) { + minDistY = p.dist + edgeNy = p.ny + guideEdgeY = p.guideY + } + } + } + } + + // 우선순위: 에지 스냅 > 중심 스냅 > 그리드 + if (typeof edgeNx === 'number') { + nx = edgeNx + } else if (typeof snappedCenterX === 'number') { + nx = snappedCenterX - selected.width / 2 + } + if (typeof edgeNy === 'number') { + ny = edgeNy + } else if (typeof snappedCenterY === 'number') { + ny = snappedCenterY - 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 }) + + updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny })) + }} + onDragEnd={() => { + // 드래그 종료 시 가이드 숨김 + setShowGuides(false) + }} + onResize={({ width, height }) => { + if (!selected) return + const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0)) + const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0)) + updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh })) + }} + 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 })) + }} />
) -- 2.52.0