diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index ad14137..45a74a2 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -32,6 +32,26 @@ 3) 최소 UI 토글(가이드/그리드) 노출 및 문서 업데이트 4) 전체 그린 확인 후 브랜치 범위 점검 → 푸쉬 +### Guides 토글 안정화 + 리사이즈 UX 2차(방향 핸들) (2025-11-17) +- TDD + - `components/wysiwyg/WysiwygBuilder.guides.acceptance.test.tsx` + - Guides 토글 버튼(ON/OFF) 시 가이드 DOM/라이브 리전 문구 반영 검증 + - `components/wysiwyg/WysiwygBuilder.resize.acceptance.test.tsx` + - 방향별 리사이즈 핸들: W/N/E/S/NE + - 좌/상 핸들: 원점 이동 + 반대변 고정, 우/하: 원점 유지 + 크기 증가, NE: 상단 이동 + 우측 확장 + - 스냅 8px/최소 크기 8px 보장. 스냅으로 인한 픽셀 변화는 snap8 기대값으로 검증 +- 구현 + - `components/wysiwyg/Canvas.tsx` + - enableGuides에 따라 드래그 중에만 가이드 노출, 종료 시 해제 + - 커스텀 리사이즈 핸들 5종(W/N/E/S/NE) 추가 및 방향성 로직 구현 + - 스냅(8px)·최소 크기(8px) 적용, 좌/상은 원점 이동 처리 + - 리사이즈 중 Moveable 드래그 이벤트 무시(간섭 방지) +- 오류 원인/디버깅 + - React 경고: "다른 컴포넌트 렌더 중 setState" → 드래그/리사이즈 라이프사이클로 이동해 해결 + - Moveable 드래그와 리사이즈 동시 발생으로 좌표 튐/기대 불일치 → `resizingRef` 활성 시 Moveable onDragStart/onDrag 조기리턴 처리 + - 테스트 불안정: 선택 직후 의도치 않은 드래그 → 선택 후 `mouseUp(canvas)`로 상호작용 종료 보장, 스냅 기대값으로 단정 +- 결과: 위 2개 수용 테스트 포함 전체 그린 유지 + ### 가이드라인 고도화 진행(2025-11-16) - 목표: 객체-객체 스냅 가이드(8px 임계) - 수직 중심 정렬 가이드/스냅 @@ -135,6 +155,78 @@ - 결과: 테스트 그린(전체 232/232) - 디버깅 메모: useEffect 내 setState 경고 발생 → useMemo로 파생 텍스트 계산으로 해결 +### 폼 요소 이관 + Export a11y + Help + Validation (2025-11-17) +- TDD + - components/wysiwyg/ + - WysiwygBuilder.form-input.acceptance.test.tsx: Input 추가·편집(Name/Label/Placeholder/Required) → Canvas 반영 + - WysiwygBuilder.form-select.acceptance.test.tsx: Select 추가·편집(Name/Label/Options/Default) → Canvas 반영 + - WysiwygBuilder.form-textarea.acceptance.test.tsx: Textarea 추가·편집(Name/Label/Placeholder/Rows/Required) → Canvas 반영 + - WysiwygBuilder.form-radio.acceptance.test.tsx: Radio 그룹 추가·편집(Name/Legend/Options/Default/Required) → Canvas 반영 + - WysiwygBuilder.form-checkbox.acceptance.test.tsx: Checkbox 그룹 추가·편집(Name/Legend/Options/Defaults/Required) → Canvas 반영 + - WysiwygBuilder.form-help.acceptance.test.tsx: Help 편집 → Canvas help 텍스트 및 aria-describedby 연결 검증 + - lib/wysiwyg/ + - export.form.test.ts: 폼 5종 a11y(label/for, fieldset/legend, selected 등) HTML/CSS 출력 검증 + - export.form.help.test.ts: Help → aria-describedby(입력)/그룹 Help(라디오/체크박스) 검증 + - export.form.validation.test.ts: pattern/minLength/maxLength 속성 내보내기 검증 +- 구현 + - lib/wysiwyg/schema.ts: input/select/textarea/radio/checkbox 타입 추가, help 및 pattern/minLength/maxLength(해당 타입) 속성 반영 + - components/wysiwyg/PropsPanel.tsx: 각 타입 속성 편집(Name/Label/Options/Default/Rows/Required/Help/Validation) + - components/wysiwyg/Canvas.tsx: 시맨틱 렌더(label/fieldset/legend), control id `${id}-control`, help id `${id}-control-help`, aria-describedby 연결 + - components/wysiwyg/WysiwygBuilderPage.tsx: Add 버튼 및 오브젝트 생성 로직 추가(5종) + - lib/wysiwyg/export.ts: 폼 5종 시맨틱 a11y 마크업 + validation 속성(pattern/min/ maxLength) 내보내기 +- 결과 + - 폼 5종 Add→Properties 편집→Canvas 반영 수용 테스트 그린 + - Export a11y/Help/Validation 테스트 그린(전체 export 회귀 유지) +- 오류 원인/디버깅 + - 테스트 쿼리 충돌(라벨 텍스트 중복) → Properties 범위로 쿼리 스코프 제한(panel.querySelector) + - 라디오/체크박스 초기 checked 불일치 → Canvas에서 제어형으로 렌더(checked, onChange no-op) + - 타입/린트: any 캐스트 제거(스키마 확장), Partial 타입에 help 키 추가로 해결 + +### 스타일 토큰화 + 상태/테마 (2025-11-17) +- TDD + - `lib/wysiwyg/export.style.tokens.test.ts`: :root 토큰 존재(--bg, --text, --border, --primary), .frame/anchor 토큰 참조 + - `lib/wysiwyg/export.style.tokens.controls.test.ts`: input/select/textarea/fieldset/legend 베이스 스타일 토큰 참조 + - `lib/wysiwyg/export.style.tokens.states.test.ts`: :focus, :invalid 상태 스타일이 토큰(--primary/--danger) 참조 + - `lib/wysiwyg/export.style.tokens.theme.test.ts`: [data-theme="light"|"dark"] 프리셋 토큰 존재 및 .frame이 var(--bg) 사용 +- 구현 + - `lib/wysiwyg/export.ts` + - :root 토큰: `--bg`, `--text`, `--border`, `--primary`, `--danger` + - 베이스 규칙: `.frame{background:var(--bg)}`, `a{color:var(--primary)}` + - 컨트롤 베이스: `input,select,textarea{color:var(--text);border:1px solid var(--border)}` + `fieldset/legend` + - 상태: `:focus{outline:2px solid var(--primary)}` / `:invalid{border-color:var(--danger)}` + - 테마 프리셋: `[data-theme="light"]`, `[data-theme="dark"]` 각각 토큰 값 제공 +- 결과: 모든 관련 테스트 그린 유지 +- 오류 원인/디버깅 + - 없음(스냅샷은 정규식 매칭으로 안정화) + +### Guides/그룹 액션 완성(드래그/리사이즈/회전 + 스냅) (2025-11-17) +- TDD + - `components/wysiwyg/WysiwygBuilder.guides.acceptance.test.tsx`: Guides 토글 ON/OFF 표시 확인 + - `components/wysiwyg/WysiwygBuilder.group-actions.acceptance.test.tsx`: 다중 선택 그룹 드래그(델타 8px 스냅), 리사이즈(E 핸들), 회전(15° 스냅) 검증 +- 구현 + - `components/wysiwyg/Canvas.tsx` + - 다중 선택 드래그: 델타 기반 8px 방향 스냅(+13→+16, -13→-16), 선택된 전체 객체에 동일 delta 적용 + - 그룹 리사이즈: 방향별 로직 유지, 그룹 원점/크기 변화 반영 + - 그룹 회전: 15° 스냅 적용(Shift 없이) + - Moveable 간섭 방지: 다중 선택 시 Moveable의 draggable/rotatable 비활성화(단일 선택에서만 활성) + - 선택 보존: 이미 선택된 객체를 다시 클릭해 드래그해도 멀티 선택 유지 +- 결과: 수용 테스트 2종 그린, 기존 회귀 그린 유지 +- 오류 원인/디버깅 + - 경고: "다른 컴포넌트 렌더 중 setState" → 다중 선택 드래그에서 Moveable와 커스텀 드래그가 중복 적용되던 이슈를 해결(멀티 시 Moveable drag/rotate 비활성화) + - 테스트 불안정: 버튼 라벨 모호 매칭(Add Text vs Add Textarea) → 정규식 고정(/^Add Text$/) + +### 린트/테스트 안정화 배치 (2025-11-17) +- 조치 + - 테스트 쿼리 모호성 제거: 모든 `Add Text` 버튼 매칭을 `name: /^Add Text$/i`로 통일 + - 훅 의존성 배열 보강: Canvas 마퀴/가이드/드래그 관련 `useCallback`/`useEffect` deps 누락 보완 + - 미사용 유틸/헬퍼 제거 및 경고 해소 + - `ts-ignore` 대체: URL/DOM 모듈 모킹을 `vi.spyOn(...)`으로 치환하여 타입 우회 제거 + - 멀티선택 시 Moveable 간섭 방지 재확인: draggable/rotatable 비활성화 유지로 중복 업데이트 차단 + - 스냅 우선순위 보존 검증: 에지 > 중심 > 그리드, 방향성 스냅(+13→+16, -13→-16) 회귀 그린 확인 +- 결과 + - `npm test -s`: 164 파일, 289 테스트 전부 그린 + - React 경고(setState in render) 제거, 테스트 플래키성 해소 + ## 목표 - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. diff --git a/components/wysiwyg/Canvas.guides.closest.acceptance.test.tsx b/components/wysiwyg/Canvas.guides.closest.acceptance.test.tsx new file mode 100644 index 0000000..b21dced --- /dev/null +++ b/components/wysiwyg/Canvas.guides.closest.acceptance.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage' + +function getLeft(el: HTMLElement) { + const style = el.getAttribute('style') || '' + const m = /left:\s*(\d+)px/ + return Number((style.match(m)?.[1]) || '0') +} + +function getTop(el: HTMLElement) { + const style = el.getAttribute('style') || '' + const m = /top:\s*(\d+)px/ + return Number((style.match(m)?.[1]) || '0') +} + +describe('Canvas guides - nearest candidate priority', () => { + it('snaps to the nearest edge candidate among multiple options', async () => { + render() + const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i })) + // Create three objects: A (will move), B and C as anchors + addText() // A + addText() // B + addText() // C + const objs = await screen.findAllByTestId(/obj-text-/) + const [A, B, C] = objs + + // Place B at x=160 (left), C at x=200 (left) via drags + // Select B and drag +100 from 60 -> 160 + fireEvent.mouseDown(B, { clientX: 60, clientY: 60 }) + const canvas = screen.getByTestId('wysiwyg-canvas') + fireEvent.mouseMove(canvas, { clientX: 160, clientY: 60 }) + fireEvent.mouseUp(canvas) + await waitFor(() => expect(getLeft(B)).toBe(160)) + + // Select C and drag +140 from 60 -> 200 + fireEvent.mouseDown(C, { clientX: 60, clientY: 120 }) + fireEvent.mouseMove(canvas, { clientX: 200, clientY: 120 }) + fireEvent.mouseUp(canvas) + await waitFor(() => expect(getLeft(C)).toBe(200)) + + // Now drag A near between 160 and 200, closer to 160, expect snap to 160 + fireEvent.mouseDown(A, { clientX: 60, clientY: 180 }) + fireEvent.mouseMove(canvas, { clientX: 168, clientY: 180 }) // delta +108 -> near 160 more than 200 + await screen.findByTestId('guide-x') + fireEvent.mouseUp(canvas) + + await waitFor(() => expect(getLeft(A)).toBe(160)) + }) +}) diff --git a/components/wysiwyg/Canvas.guides.priority.acceptance.test.tsx b/components/wysiwyg/Canvas.guides.priority.acceptance.test.tsx new file mode 100644 index 0000000..4dd5e79 --- /dev/null +++ b/components/wysiwyg/Canvas.guides.priority.acceptance.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage' + +function getLeft(el: HTMLElement) { + const style = el.getAttribute('style') || '' + const m = /left:\s*(\d+)px/ + return Number((style.match(m)?.[1]) || '0') +} + +describe('Canvas guides - edge vs center priority', () => { + it('prefers edge snap over center when both are available (edge>center)', async () => { + render() + // Add two texts + const clickAddText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i })) + clickAddText() + clickAddText() + const objs = await screen.findAllByTestId(/obj-text-/) + const a = objs[0] + const b = objs[1] + + // Select A and drag to x≈100 (from x=60, delta≈+40 -> snaps to +40) + fireEvent.mouseDown(a, { clientX: 60, clientY: 60 }) + const canvas = screen.getByTestId('wysiwyg-canvas') + fireEvent.mouseMove(canvas, { clientX: 100, clientY: 60 }) + fireEvent.mouseUp(canvas) + + // Now A left≈100, width=160 so center≈180 + await waitFor(() => expect(getLeft(a)).toBe(100)) + + // Select B and drag so that B left≈100 too; both edge(L-L) and center-center coincide + fireEvent.mouseDown(b, { clientX: 120, clientY: 60 }) + fireEvent.mouseMove(canvas, { clientX: 159, clientY: 60 }) // delta≈+39 => snapped +40 → left 100 + // guides should show during drag + await screen.findByTestId('guide-x') + fireEvent.mouseUp(canvas) + + await waitFor(() => expect(getLeft(b)).toBe(100)) + }) +}) diff --git a/components/wysiwyg/Canvas.multiselect.resize.acceptance.test.tsx b/components/wysiwyg/Canvas.multiselect.resize.acceptance.test.tsx new file mode 100644 index 0000000..960f463 --- /dev/null +++ b/components/wysiwyg/Canvas.multiselect.resize.acceptance.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage' + +function getBoxes(ids: string[]) { + return ids.map((id) => { + const el = screen.getByTestId(`obj-${id}`) as HTMLElement + const s = el.style + return { + id, + left: parseInt(s.left || '0', 10), + top: parseInt(s.top || '0', 10), + width: parseInt(s.width || '0', 10), + height: parseInt(s.height || '0', 10), + } + }) +} + +function snap8(v: number) { return Math.round(v / 8) * 8 } + +async function addTextAt(x: number, y: number) { + fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i })) + const all = await screen.findAllByTestId(/obj-text-/) + const obj = all[all.length - 1] as HTMLElement + // select the newly added object + fireEvent.mouseDown(obj, { clientX: x, clientY: y }) + fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas')) + // move via props inputs (X/Y) for the selected object + const xInput = screen.getByLabelText('X') as HTMLInputElement + const yInput = screen.getByLabelText('Y') as HTMLInputElement + fireEvent.change(xInput, { target: { value: String(x) } }) + fireEvent.change(yInput, { target: { value: String(y) } }) + return obj.getAttribute('data-testid')!.replace('obj-', '') +} + +describe('Canvas multiselect directional resize', () => { + it('east handle increases width of all selected without shifting their x', async () => { + render() + const id1 = await addTextAt(60, 60) + const id2 = await addTextAt(140, 60) + + const canvas = screen.getByTestId('wysiwyg-canvas') + // marquee select both + fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 }) + fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 }) + fireEvent.mouseUp(canvas) + + // wait until aria-live announces 2 selections + const live1 = await screen.findByTestId('aria-live') + expect(live1.textContent || '').toMatch(/선택\s*2개/) + const before = getBoxes([id1, id2]) + const hRight = await screen.findByTestId(`resize-handle-e-${id2}`) + fireEvent.mouseDown(hRight, { clientX: before[1].left + before[1].width, clientY: before[1].top + 5 }) + fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[1].left + before[1].width + 16, clientY: before[1].top + 5 }) + fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas')) + + const after = getBoxes([id1, id2]) + const dw = snap8(before[1].width + 16) - before[1].width + after.forEach((b, i) => { + expect(b.left).toBe(before[i].left) + expect(b.width).toBe(before[i].width + dw) + expect(b.top).toBe(before[i].top) + expect(b.height).toBe(before[i].height) + }) + }) + + it('west handle moves x of all selected left and increases their width equally', async () => { + render() + const id1 = await addTextAt(60, 60) + const id2 = await addTextAt(140, 60) + + const canvas = screen.getByTestId('wysiwyg-canvas') + fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 }) + fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 }) + fireEvent.mouseUp(canvas) + const before = getBoxes([id1, id2]) + const hLeft = await screen.findByTestId(`resize-handle-w-${id1}`) + fireEvent.mouseDown(hLeft, { clientX: before[0].left, clientY: before[0].top + 5 }) + fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[0].left - 16, clientY: before[0].top + 5 }) + fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas')) + + const after = getBoxes([id1, id2]) + const right0 = before[0].left + before[0].width + const expLeft0 = Math.min(snap8(before[0].left - 16), right0 - 8) + const dx = expLeft0 - before[0].left + const dw = (right0 - expLeft0) - before[0].width + + after.forEach((b, i) => { + expect(b.left).toBe(before[i].left + dx) + expect(b.width).toBe(before[i].width + dw) + expect(b.top).toBe(before[i].top) + expect(b.height).toBe(before[i].height) + }) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 63c1ac3..0c37528 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -2,16 +2,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' -import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap' function snap8(v: number) { return Math.round(v / 8) * 8 } -// Shift 키 타입 가드: unknown 입력에서 shiftKey 존재 여부를 안전하게 판별 -function isShiftEvent(ev: unknown): ev is { shiftKey?: boolean } { - return typeof ev === 'object' && ev !== null && 'shiftKey' in (ev as Record) -} +// (removed unused type guard) // 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가 type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null } @@ -26,9 +22,10 @@ export type CanvasProps = { frame: Frame onChange?: (next: Frame) => void onSelectIds?: (ids: string[]) => void + enableGuides?: boolean } -export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { +export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) { const [model, setModel] = useState(frame) // 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화 useEffect(() => { setModel(frame) }, [frame]) @@ -36,8 +33,9 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { const [selectedIds, setSelectedIds] = useState(initialId ? [initialId] : []) const selectedId = selectedIds[selectedIds.length - 1] ?? null const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null) - const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null) - const resizeGroupRef = useRef<{ ids: string[]; orig: Record } | null>(null) + const [isDragging, setIsDragging] = useState(false) + const resizingRef = useRef<{ id: string; dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null) + const resizeGroupRef = useRef<{ ids: string[]; orig: Record } | null>(null) const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const rotateGroupRef = useRef<{ ids: string[]; orig: Record } | null>(null) const [showGuides, setShowGuides] = useState(false) @@ -60,14 +58,20 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { return null }, [model, selectedId]) + // enableGuides가 꺼지면 즉시 가이드를 숨긴다 + useEffect(() => { + if (!enableGuides && showGuides) setShowGuides(false) + if (!enableGuides && isDragging) setIsDragging(false) + }, [enableGuides, showGuides, isDragging]) + // a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림 const ariaLiveText = useMemo(() => { const count = selectedIds.length const base = `선택 ${count}개` const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : '' - const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : '' + const guide = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : '' return `${base}${pos}${guide}` - }, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y]) + }, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected]) const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => { setModel((prev) => { @@ -126,16 +130,28 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { return next }) } else { - const next = [o.id] - setSelectedIds(next) - try { onSelectIds?.(next) } catch {} + setSelectedIds((prev) => { + // if already selected, preserve current multi-selection + if (prev.includes(o.id)) { + try { onSelectIds?.(prev) } catch {} + return prev + } + const next = [o.id] + try { onSelectIds?.(next) } catch {} + return next + }) } draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y } - setShowGuides(true) - setGuidePos({ x: snap8(o.x), y: snap8(o.y) }) - }, [selectedIds, model.layers]) + setIsDragging(true) + if (enableGuides) { + setShowGuides(true) + setGuidePos({ x: snap8(o.x), y: snap8(o.y) }) + } + }, [selectedIds, model.layers, onSelectIds, enableGuides]) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { + // guides 비활성화 상태에서 남아있다면 즉시 숨김 + if (!enableGuides && showGuides) setShowGuides(false) // 마퀴 선택 처리 우선 if (marquee.active) { const x1 = e.clientX @@ -186,58 +202,205 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { if (r) { const dx = e.clientX - r.startX const dy = e.clientY - r.startY - const nw = Math.max(8, snap8(r.origW + dx)) - const nh = Math.max(8, snap8(r.origH + dy)) + let nx = r.origX + let ny = r.origY + let nw = r.origW + let nh = r.origH + + if (r.dir === 'e') { + nw = Math.max(8, snap8(r.origW + dx)) + } else if (r.dir === 's') { + nh = Math.max(8, snap8(r.origH + dy)) + } else if (r.dir === 'w') { + // left edge moves, right edge fixed + const tentativeLeft = snap8(r.origX + dx) + const right = r.origX + r.origW + nx = Math.min(tentativeLeft, right - 8) + nw = Math.max(8, right - nx) + } else if (r.dir === 'n') { + // top edge moves, bottom fixed + const tentativeTop = snap8(r.origY + dy) + const bottom = r.origY + r.origH + ny = Math.min(tentativeTop, bottom - 8) + nh = Math.max(8, bottom - ny) + } else if (r.dir === 'ne') { + // top moves like 'n', right like 'e' + const tentativeTop = snap8(r.origY + dy) + const bottom = r.origY + r.origH + ny = Math.min(tentativeTop, bottom - 8) + nh = Math.max(8, bottom - ny) + nw = Math.max(8, snap8(r.origW + dx)) + } else if (r.dir === 'se') { + // bottom-right: origin stays, width/height increase + nw = Math.max(8, snap8(r.origW + dx)) + nh = Math.max(8, snap8(r.origH + dy)) + } else if (r.dir === 'nw') { + // top-left: move origin up/left, keep bottom-right fixed + const right = r.origX + r.origW + const bottom = r.origY + r.origH + const tentativeLeft = snap8(r.origX + dx) + const tentativeTop = snap8(r.origY + dy) + nx = Math.min(tentativeLeft, right - 8) + ny = Math.min(tentativeTop, bottom - 8) + nw = Math.max(8, right - nx) + nh = Math.max(8, bottom - ny) + } else if (r.dir === 'sw') { + // bottom-left: move left edge, increase height + const right = r.origX + r.origW + const tentativeLeft = snap8(r.origX + dx) + nx = Math.min(tentativeLeft, right - 8) + nw = Math.max(8, right - nx) + nh = Math.max(8, snap8(r.origH + dy)) + } + + // single selection vs multi-selection 적용 if (selectedIds.length > 1 && resizeGroupRef.current) { const { ids, orig } = resizeGroupRef.current const base = orig[r.id] const dw = nw - base.w const dh = nh - base.h + // 서/북 방향 포함 시 원점 이동량을 그룹 전체에 동일 적용 + const dxShift = nx - r.origX + const dyShift = ny - r.origY updateObjects(ids, (o) => { const o0 = orig[o.id] if (!o0) return o - return { + const next: typeof o = { ...o, width: Math.max(8, o0.w + dw), height: Math.max(8, o0.h + dh), } + // 좌측/상단 이동이 있는 경우에만 좌표 이동 적용 + if (dxShift !== 0) next.x = o0.x + dxShift + if (dyShift !== 0) next.y = o0.y + dyShift + return next }) } else { - updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) + updateObject(r.id, (o) => ({ ...o, x: nx, y: ny, width: nw, height: nh })) } return } const d = draggingRef.current if (d) { + if (enableGuides && !showGuides) setShowGuides(true) const dx = e.clientX - d.startX const dy = e.clientY - d.startY - const nx = snap8(d.origX + dx) - const ny = snap8(d.origY + dy) + // compute raw target + const rawLeft = d.origX + dx + const rawTop = d.origY + dy + // if raw lands exactly on 8px grid, keep it; else apply directional delta snap + const onGrid = (v: number) => v % 8 === 0 + const snapDelta = (delta: number) => (delta >= 0 ? Math.ceil(delta / 8) : Math.floor(delta / 8)) * 8 + let nx = onGrid(rawLeft) ? rawLeft : d.origX + snapDelta(dx) + let ny = onGrid(rawTop) ? rawTop : d.origY + snapDelta(dy) + + // object-to-object snapping (edge > center > grid) with threshold 8px + const threshold = 8 + // find current selected object dimensions + let selW = 0, selH = 0 + for (const ly of model.layers) { + const ob = ly.objects.find((o) => o.id === d.id) + if (ob) { selW = ob.width; selH = ob.height; break } + } + let snappedCenterX: number | undefined + let snappedCenterY: number | undefined + let centerDistX = Number.POSITIVE_INFINITY + let centerDistY = Number.POSITIVE_INFINITY + let edgeNx: number | undefined + let edgeNy: number | undefined + let minDistX = Number.POSITIVE_INFINITY + let minDistY = Number.POSITIVE_INFINITY + for (const layer of model.layers) { + for (const obj of layer.objects) { + if (obj.id === d.id) continue + // center snap + const otherCenterX = obj.x + obj.width / 2 + const selCenterX = rawLeft + selW / 2 + const dxCenter = Math.abs(selCenterX - otherCenterX) + if (dxCenter <= threshold && dxCenter < centerDistX) { + centerDistX = dxCenter + snappedCenterX = otherCenterX + } + const otherCenterY = obj.y + obj.height / 2 + const selCenterY = rawTop + selH / 2 + const dyCenter = Math.abs(selCenterY - otherCenterY) + if (dyCenter <= threshold && dyCenter < centerDistY) { + centerDistY = dyCenter + snappedCenterY = otherCenterY + } + // edge snap X + const selLeft = rawLeft + const selRight = rawLeft + selW + const otherLeft = obj.x + const otherRight = obj.x + obj.width + const xPairs: Array<{ dist: number; nx: number }> = [ + { dist: Math.abs(selLeft - otherLeft), nx: otherLeft }, + { dist: Math.abs(selLeft - otherRight), nx: otherRight }, + { dist: Math.abs(selRight - otherLeft), nx: otherLeft - selW }, + { dist: Math.abs(selRight - otherRight), nx: otherRight - selW }, + ] + for (const p of xPairs) { + if (p.dist <= threshold && p.dist < minDistX) { + minDistX = p.dist + edgeNx = p.nx + } + } + // edge snap Y + const selTop = rawTop + const selBottom = rawTop + selH + const otherTop = obj.y + const otherBottom = obj.y + obj.height + const yPairs: Array<{ dist: number; ny: number }> = [ + { dist: Math.abs(selTop - otherTop), ny: otherTop }, + { dist: Math.abs(selTop - otherBottom), ny: otherBottom }, + { dist: Math.abs(selBottom - otherTop), ny: otherTop - selH }, + { dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selH }, + ] + for (const p of yPairs) { + if (p.dist <= threshold && p.dist < minDistY) { + minDistY = p.dist + edgeNy = p.ny + } + } + } + } + if (typeof edgeNx === 'number') nx = edgeNx + else if (typeof snappedCenterX === 'number') nx = snappedCenterX - selW / 2 + if (typeof edgeNy === 'number') ny = edgeNy + else if (typeof snappedCenterY === 'number') ny = snappedCenterY - selH / 2 setGuidePos({ x: nx, y: ny }) - updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) + if (selectedIds.length > 1) { + const ddx = nx - d.origX + const ddy = ny - d.origY + updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy })) + } else { + updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) + } } - }, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length]) + }, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds, selectedIds.length, enableGuides, onSelectIds, showGuides]) const onMouseUpCanvas = useCallback(() => { if (marquee.active) { setMarquee((m) => ({ ...m, active: false })) } + // 상호작용 종료 시 가이드 숨김 + if (showGuides) setShowGuides(false) + setIsDragging(false) draggingRef.current = null resizingRef.current = null resizeGroupRef.current = null rotatingRef.current = null - setShowGuides(false) - }, [marquee.active]) + }, [marquee.active, showGuides]) - const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { + const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => { e.stopPropagation() - resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height } + resizingRef.current = { id: o.id, dir, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y, origW: o.width, origH: o.height } // capture originals for group resize const ids = Array.from(new Set([...selectedIds, o.id])) - const orig: Record = {} + const orig: Record = {} for (const ly of model.layers) { for (const ob of ly.objects) { - if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height } + if (ids.includes(ob.id)) orig[ob.id] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height } } } resizeGroupRef.current = { ids, orig } @@ -276,7 +439,7 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }} data-testid="wysiwyg-canvas" > - {showGuides && ( + {enableGuides && (showGuides || isDragging) && ( <>
@@ -316,12 +479,143 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) { {o.props.label} )} + {o.type === 'input' && (() => { + const cid = `${o.id}-control` + const helpId = o.props.help ? `${cid}-help` : '' + return ( + + ) + })()} + {o.type === 'select' && (() => { + const cid = `${o.id}-control` + const helpId = o.props.help ? `${cid}-help` : '' + return ( + + ) + })()} + {o.type === 'textarea' && (() => { + const cid = `${o.id}-control` + const helpId = o.props.help ? `${cid}-help` : '' + return ( +
`, + ].join('') + return { html, css: baseCss } + } + if (isRadio(o)) { + const req = o.props.required ? ' required' : '' + const items = (o.props.options || []).map((opt, idx) => { + const id = `${o.id}-${idx}` + const checked = o.props.defaultValue === opt ? ' checked' : '' + return `
` + }).join('') + const help = o.props.help ? `${esc(o.props.help)}` : '' + const html = [`
`, `${esc(o.props.legend)}`, items, help, `
`].join('') + return { html, css: baseCss } + } + if (isCheckbox(o)) { + const req = o.props.required ? ' required' : '' + const def = new Set(o.props.defaultValues || []) + const items = (o.props.options || []).map((opt, idx) => { + const id = `${o.id}-${idx}` + const checked = def.has(opt) ? ' checked' : '' + return `
` + }).join('') + const help = o.props.help ? `${esc(o.props.help)}` : '' + const html = [`
`, `${esc(o.props.legend)}`, items, help, `
`].join('') + return { html, css: baseCss } + } + // button minimal (fallback) const label = isButton(o) ? o.props.label : 'Button' const href = isButton(o) ? (o.props.href || '#') : '#' const html = `${esc(label)}` @@ -40,8 +120,17 @@ export function exportFrame(frame: Frame): Exported { } const inner = `
${piecesHtml.join('')}
` const css = [ - `.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`, + `:root{--bg:${frame.background};--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444}`, + `[data-theme="light"]{--bg:#ffffff;--text:#0f172a;--border:#cbd5e1;--primary:#0ea5e9;--danger:#ef4444}`, + `[data-theme="dark"]{--bg:#111111;--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444}`, + `.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:var(--bg)}`, `.frame-scale{transform-origin:0 0}`, + `a{color:var(--primary);text-decoration:none}`, + `input,select,textarea{color:var(--text);background:transparent;border:1px solid var(--border)}`, + `input:focus,select:focus,textarea:focus{outline:2px solid var(--primary);outline-offset:2px}`, + `input:invalid,select:invalid,textarea:invalid{border-color:var(--danger)}`, + `fieldset{border:1px solid var(--border)}`, + `legend{color:var(--text)}`, ...piecesCss, ].join('\n') const html = [ diff --git a/lib/wysiwyg/schema.ts b/lib/wysiwyg/schema.ts index c984590..6176d67 100644 --- a/lib/wysiwyg/schema.ts +++ b/lib/wysiwyg/schema.ts @@ -39,7 +39,70 @@ const buttonObject = baseObject.extend({ }), }) -export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject]) +const inputObject = baseObject.extend({ + type: z.literal('input'), + props: z.object({ + name: z.string().min(1), + label: z.string().min(1), + placeholder: z.string().optional().default(''), + required: z.boolean().optional().default(false), + pattern: z.string().optional(), + minLength: z.number().int().nonnegative().optional(), + maxLength: z.number().int().nonnegative().optional(), + help: z.string().optional(), + }), +}) + +const selectObject = baseObject.extend({ + type: z.literal('select'), + props: z.object({ + name: z.string().min(1), + label: z.string().min(1), + options: z.array(z.string()).default([]), + defaultValue: z.string().optional().default(''), + help: z.string().optional(), + }), +}) + +const textareaObject = baseObject.extend({ + type: z.literal('textarea'), + props: z.object({ + name: z.string().min(1), + label: z.string().min(1), + placeholder: z.string().optional().default(''), + rows: z.number().int().positive().default(3), + required: z.boolean().optional().default(false), + minLength: z.number().int().nonnegative().optional(), + maxLength: z.number().int().nonnegative().optional(), + help: z.string().optional(), + }), +}) + +const radioObject = baseObject.extend({ + type: z.literal('radio'), + props: z.object({ + name: z.string().min(1), + legend: z.string().min(1), + options: z.array(z.string()).default([]), + defaultValue: z.string().optional().default(''), + required: z.boolean().optional().default(false), + help: z.string().optional(), + }), +}) + +const checkboxObject = baseObject.extend({ + type: z.literal('checkbox'), + props: z.object({ + name: z.string().min(1), + legend: z.string().min(1), + options: z.array(z.string()).default([]), + defaultValues: z.array(z.string()).default([]), + required: z.boolean().optional().default(false), + help: z.string().optional(), + }), +}) + +export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject, inputObject, selectObject, textareaObject, radioObject, checkboxObject]) export type WObject = z.infer export const layerSchema = z.object({