Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64790cf991 | |||
| adb96080a8 | |||
| 77429f7f51 | |||
| d5b51a4eea | |||
| 545c906c7b | |||
| 2bce81081e | |||
| e3a8cf84a0 | |||
| 28f9548f16 | |||
| f7fa433bdd | |||
| 8f51494433 | |||
| e602b16254 | |||
| e4287b8567 | |||
| d08f050bfd | |||
| d2575c9b19 | |||
| dc54226351 | |||
| 5bf78b3e01 |
+156
@@ -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,142 @@
|
||||
- 결과: 테스트 그린(전체 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"]` 각각 토큰 값 제공
|
||||
- 결과: 모든 관련 테스트 그린 유지
|
||||
- 오류 원인/디버깅
|
||||
- 없음(스냅샷은 정규식 매칭으로 안정화)
|
||||
|
||||
### 스타일 토큰 회귀 강화(링/비활성) (2025-11-17)
|
||||
- TDD
|
||||
- `lib/wysiwyg/export.style.tokens.focusVisible.test.ts`: `--ring`, `--ring-offset` 토큰 존재 및 `:focus-visible`에서 outline/offset 참조 검증
|
||||
- `lib/wysiwyg/export.style.tokens.disabled.test.ts`: `--disabled-bg`, `--disabled-fg` 토큰 존재 및 `:disabled`/`[readonly]`에서 참조 검증
|
||||
- 구현: `lib/wysiwyg/export.ts`
|
||||
- :root/테마 토큰에 `--ring`, `--ring-offset`, `--disabled-bg`, `--disabled-fg` 추가
|
||||
- 규칙 추가: `:focus-visible{outline:2px solid var(--ring);outline-offset:var(--ring-offset)}`
|
||||
- 규칙 추가: `input,select,textarea:disabled{background:var(--disabled-bg);color:var(--disabled-fg)}` / `[readonly]{color:var(--disabled-fg)}`
|
||||
- 결과: 전체 테스트 그린(166 파일 / 291 테스트)
|
||||
|
||||
### 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) 제거, 테스트 플래키성 해소
|
||||
|
||||
### 키보드 nudge × 멀티선택 복합 (2025-11-17)
|
||||
- TDD
|
||||
- `components/wysiwyg/Canvas.nudge.multiselect.acceptance.test.tsx`
|
||||
- ArrowRight: 1px 이동 후 그리드 스냅 적용(이동 축만 스냅)
|
||||
- Shift+ArrowUp: 스냅 없이 정확히 -1px 이동(미세 이동)
|
||||
- Alt+ArrowRight: +16px 이동 후 그리드 스냅(가속 이동)
|
||||
- 구현: `components/wysiwyg/Canvas.tsx`
|
||||
- `onKeyDown`: 앵커(마지막 선택) 기준 목표 좌표(tx, ty) → 스냅 적용(nx, ny) → 델타(ddx, ddy) 산출 → 선택 집합 전체에 동일 델타 적용
|
||||
- 이동하지 않은 축은 기존 좌표 유지하여 비의도 축 보정 방지
|
||||
- 결과: 전체 테스트 그린(168 파일 / 295 테스트)
|
||||
|
||||
### CI E2E 수정: 마퀴 시작 판정 완화 (2025-11-17)
|
||||
- 원인: 빈 영역 드래그 시에도 마퀴가 시작해야 하나, 캔버스 data-testid 직접 비교로 인해 실제 히트박스에서 시작 누락 → aria-live "선택 0개" 유지
|
||||
- 조치: `components/wysiwyg/Canvas.tsx`의 `onMouseDown`에서 객체/핸들 근접 여부 검사(`closest`)로 판정 완화, 객체/핸들이 아닌 영역이면 항상 마퀴 시작
|
||||
- 결과: E2E 라이브리전 멀티선택 문구 안정화, 전체 테스트 그린 유지
|
||||
|
||||
### E2E 스모크: 그룹 드래그 스냅 델타 동일 적용 (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-group-drag.e2e.ts`
|
||||
- 마퀴로 2개 선택 후, 선택 중 하나를 +16px 드래그 → 8px 스냅 반영 동일 델타로 양 객체 이동 검증
|
||||
- 기대/결과: 스냅된 델타(dx)가 선택 전체에 동일하게 적용됨 확인
|
||||
|
||||
### E2E 스모크 확장: 그룹 리사이즈(E)/회전(15°) + 가이드 라이브리전 (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-group-resize-rotate.e2e.ts`
|
||||
- 그룹 E 핸들 리사이즈: 좌표 고정, width만 스냅 증가, 두 객체 동일 증분
|
||||
- 회전 핸들: 15° 단위 스냅, 드래그 중 라이브리전에 가이드 좌표 문구 포함
|
||||
- 결과: 그룹 액션 스냅/가이드 UX 안정화 스모크 통과
|
||||
|
||||
### 이미지 최적화 옵션 2차 TDD (2025-11-17)
|
||||
- 테스트: `lib/exporter/image.responsive.options.matrix.test.ts`
|
||||
- loading/decoding/fetchpriority 적용
|
||||
- srcset + autoSizes + preset 동시 제공 시 preset 우선
|
||||
- explicit sizes가 있으면 preset/autoSizes보다 우선
|
||||
- 구현 상태: `lib/exporter/html.ts`의 기존 로직이 요구사항 충족(우선순위: explicit > preset > default(autoSizes))
|
||||
|
||||
### Export CSS 토큰 딥 회귀 (폼/테마) (2025-11-17)
|
||||
- 테스트: `lib/wysiwyg/export.style.tokens.form.deep.test.ts`
|
||||
- 토큰 존재: `--ring`, `--ring-offset`, `--disabled-bg`, `--disabled-fg`
|
||||
- 사용 검증: `:focus-visible` 링/오프셋, `:disabled` BG, `[readonly]` FG
|
||||
- 테마 프리셋: `[data-theme="light|dark"]`에서 `--bg` 제공, `.frame{background:var(--bg)}` 유지
|
||||
- 결과: 토큰/참조/테마 오버라이드 회귀 보강, 전체 테스트 그린(170 파일/300 테스트)
|
||||
|
||||
### 스냅 우선순위 E2E (에지 > 센터 > 그리드) (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-snap-priority.e2e.ts`
|
||||
- 에지 스냅 우선: 인접 객체 에지 정렬이 그리드보다 우선 적용
|
||||
- 센터 스냅 우선: 중심 정렬 후보 시 그리드보다 센터 스냅 우선
|
||||
- 그리드 단독: 인접 후보가 없을 때 13px → 16px 그리드 스냅
|
||||
- 결과: 스냅 우선순위 정책(에지 > 센터 > 그리드) 회귀 스모크 확보
|
||||
|
||||
### 회전 후 스냅 유지 E2E (에지 > 그리드) (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-rotate-snap.e2e.ts`
|
||||
- 객체를 15° 회전 후 드래그해도 에지 스냅이 그리드보다 우선 적용됨 검증
|
||||
- 드래그 중 라이브리전 가이드 텍스트 노출 확인(가이드 x/y)
|
||||
- 결과: 회전 상태에서도 스냅 우선순위 일관성 확보, 라이브리전 가이드 동작 스모크 통과
|
||||
|
||||
## 목표
|
||||
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
||||
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
function getLeft(el: HTMLElement) {
|
||||
const style = el.getAttribute('style') || ''
|
||||
const m = /left:\s*(\d+)px/
|
||||
return Number((style.match(m)?.[1]) || '0')
|
||||
}
|
||||
|
||||
function getTop(el: HTMLElement) {
|
||||
const style = el.getAttribute('style') || ''
|
||||
const m = /top:\s*(\d+)px/
|
||||
return Number((style.match(m)?.[1]) || '0')
|
||||
}
|
||||
|
||||
describe('Canvas guides - nearest candidate priority', () => {
|
||||
it('snaps to the nearest edge candidate among multiple options', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
// Create three objects: A (will move), B and C as anchors
|
||||
addText() // A
|
||||
addText() // B
|
||||
addText() // C
|
||||
const objs = await screen.findAllByTestId(/obj-text-/)
|
||||
const [A, B, C] = objs
|
||||
|
||||
// Place B at x=160 (left), C at x=200 (left) via drags
|
||||
// Select B and drag +100 from 60 -> 160
|
||||
fireEvent.mouseDown(B, { clientX: 60, clientY: 60 })
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseMove(canvas, { clientX: 160, clientY: 60 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
await waitFor(() => expect(getLeft(B)).toBe(160))
|
||||
|
||||
// Select C and drag +140 from 60 -> 200
|
||||
fireEvent.mouseDown(C, { clientX: 60, clientY: 120 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 200, clientY: 120 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
await waitFor(() => expect(getLeft(C)).toBe(200))
|
||||
|
||||
// Now drag A near between 160 and 200, closer to 160, expect snap to 160
|
||||
fireEvent.mouseDown(A, { clientX: 60, clientY: 180 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 168, clientY: 180 }) // delta +108 -> near 160 more than 200
|
||||
await screen.findByTestId('guide-x')
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
await waitFor(() => expect(getLeft(A)).toBe(160))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
function getLeft(el: HTMLElement) {
|
||||
const style = el.getAttribute('style') || ''
|
||||
const m = /left:\s*(\d+)px/
|
||||
return Number((style.match(m)?.[1]) || '0')
|
||||
}
|
||||
|
||||
describe('Canvas guides - edge vs center priority', () => {
|
||||
it('prefers edge snap over center when both are available (edge>center)', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
// Add two texts
|
||||
const clickAddText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
clickAddText()
|
||||
clickAddText()
|
||||
const objs = await screen.findAllByTestId(/obj-text-/)
|
||||
const a = objs[0]
|
||||
const b = objs[1]
|
||||
|
||||
// Select A and drag to x≈100 (from x=60, delta≈+40 -> snaps to +40)
|
||||
fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseMove(canvas, { clientX: 100, clientY: 60 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
// Now A left≈100, width=160 so center≈180
|
||||
await waitFor(() => expect(getLeft(a)).toBe(100))
|
||||
|
||||
// Select B and drag so that B left≈100 too; both edge(L-L) and center-center coincide
|
||||
fireEvent.mouseDown(b, { clientX: 120, clientY: 60 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 159, clientY: 60 }) // delta≈+39 => snapped +40 → left 100
|
||||
// guides should show during drag
|
||||
await screen.findByTestId('guide-x')
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
await waitFor(() => expect(getLeft(b)).toBe(100))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
function getBoxes(ids: string[]) {
|
||||
return ids.map((id) => {
|
||||
const el = screen.getByTestId(`obj-${id}`) as HTMLElement
|
||||
const s = el.style
|
||||
return {
|
||||
id,
|
||||
left: parseInt(s.left || '0', 10),
|
||||
top: parseInt(s.top || '0', 10),
|
||||
width: parseInt(s.width || '0', 10),
|
||||
height: parseInt(s.height || '0', 10),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
async function addTextAt(x: number, y: number) {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const all = await screen.findAllByTestId(/obj-text-/)
|
||||
const obj = all[all.length - 1] as HTMLElement
|
||||
// select the newly added object
|
||||
fireEvent.mouseDown(obj, { clientX: x, clientY: y })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
// move via props inputs (X/Y) for the selected object
|
||||
const xInput = screen.getByLabelText('X') as HTMLInputElement
|
||||
const yInput = screen.getByLabelText('Y') as HTMLInputElement
|
||||
fireEvent.change(xInput, { target: { value: String(x) } })
|
||||
fireEvent.change(yInput, { target: { value: String(y) } })
|
||||
return obj.getAttribute('data-testid')!.replace('obj-', '')
|
||||
}
|
||||
|
||||
describe('Canvas multiselect directional resize', () => {
|
||||
it('east handle increases width of all selected without shifting their x', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const id1 = await addTextAt(60, 60)
|
||||
const id2 = await addTextAt(140, 60)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
// marquee select both
|
||||
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
// wait until aria-live announces 2 selections
|
||||
const live1 = await screen.findByTestId('aria-live')
|
||||
expect(live1.textContent || '').toMatch(/선택\s*2개/)
|
||||
const before = getBoxes([id1, id2])
|
||||
const hRight = await screen.findByTestId(`resize-handle-e-${id2}`)
|
||||
fireEvent.mouseDown(hRight, { clientX: before[1].left + before[1].width, clientY: before[1].top + 5 })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[1].left + before[1].width + 16, clientY: before[1].top + 5 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBoxes([id1, id2])
|
||||
const dw = snap8(before[1].width + 16) - before[1].width
|
||||
after.forEach((b, i) => {
|
||||
expect(b.left).toBe(before[i].left)
|
||||
expect(b.width).toBe(before[i].width + dw)
|
||||
expect(b.top).toBe(before[i].top)
|
||||
expect(b.height).toBe(before[i].height)
|
||||
})
|
||||
})
|
||||
|
||||
it('west handle moves x of all selected left and increases their width equally', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const id1 = await addTextAt(60, 60)
|
||||
const id2 = await addTextAt(140, 60)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
const before = getBoxes([id1, id2])
|
||||
const hLeft = await screen.findByTestId(`resize-handle-w-${id1}`)
|
||||
fireEvent.mouseDown(hLeft, { clientX: before[0].left, clientY: before[0].top + 5 })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before[0].left - 16, clientY: before[0].top + 5 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBoxes([id1, id2])
|
||||
const right0 = before[0].left + before[0].width
|
||||
const expLeft0 = Math.min(snap8(before[0].left - 16), right0 - 8)
|
||||
const dx = expLeft0 - before[0].left
|
||||
const dw = (right0 - expLeft0) - before[0].width
|
||||
|
||||
after.forEach((b, i) => {
|
||||
expect(b.left).toBe(before[i].left + dx)
|
||||
expect(b.width).toBe(before[i].width + dw)
|
||||
expect(b.top).toBe(before[i].top)
|
||||
expect(b.height).toBe(before[i].height)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
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 as CSSStyleDeclaration
|
||||
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 keyboard nudge', () => {
|
||||
it('ArrowRight applies 1px then grid snap to both (no modifier)', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const id1 = await addTextAt(60, 60)
|
||||
const id2 = await addTextAt(140, 60)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
// marquee select both
|
||||
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const before = getBoxes([id1, id2])
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
|
||||
const after = getBoxes([id1, id2])
|
||||
const expLeft0 = snap8(before[0].left + 1)
|
||||
const dx = expLeft0 - before[0].left
|
||||
after.forEach((b, i) => {
|
||||
expect(b.left).toBe(before[i].left + dx)
|
||||
expect(b.top).toBe(before[i].top)
|
||||
})
|
||||
})
|
||||
|
||||
it('Shift+ArrowUp moves exactly -1px on Y for both (no snap)', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const id1 = await addTextAt(60, 60)
|
||||
const id2 = await addTextAt(140, 60)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const before = getBoxes([id1, id2])
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowUp', shiftKey: true })
|
||||
const after = getBoxes([id1, id2])
|
||||
after.forEach((b, i) => {
|
||||
expect(b.top).toBe(before[i].top - 1)
|
||||
expect(b.left).toBe(before[i].left)
|
||||
})
|
||||
})
|
||||
|
||||
it('Alt+ArrowRight moves +16px then snaps to grid for both', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const id1 = await addTextAt(60, 60)
|
||||
const id2 = await addTextAt(140, 60)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(canvas, { clientX: 50, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 240, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const before = getBoxes([id1, id2])
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight', altKey: true })
|
||||
const after = getBoxes([id1, id2])
|
||||
const expLeft0 = snap8(before[0].left + 16)
|
||||
const dx = expLeft0 - before[0].left
|
||||
after.forEach((b, i) => {
|
||||
expect(b.left).toBe(before[i].left + dx)
|
||||
expect(b.top).toBe(before[i].top)
|
||||
})
|
||||
})
|
||||
})
|
||||
+365
-51
@@ -2,16 +2,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Moveable from 'react-moveable'
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap'
|
||||
|
||||
function snap8(v: number) {
|
||||
return Math.round(v / 8) * 8
|
||||
}
|
||||
|
||||
// Shift 키 타입 가드: unknown 입력에서 shiftKey 존재 여부를 안전하게 판별
|
||||
function isShiftEvent(ev: unknown): ev is { shiftKey?: boolean } {
|
||||
return typeof ev === 'object' && ev !== null && 'shiftKey' in (ev as Record<string, unknown>)
|
||||
}
|
||||
// (removed unused type guard)
|
||||
|
||||
// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가
|
||||
type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null }
|
||||
@@ -26,9 +22,10 @@ export type CanvasProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
onSelectIds?: (ids: string[]) => void
|
||||
enableGuides?: boolean
|
||||
}
|
||||
|
||||
export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
|
||||
useEffect(() => { setModel(frame) }, [frame])
|
||||
@@ -36,8 +33,9 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
|
||||
const selectedId = selectedIds[selectedIds.length - 1] ?? null
|
||||
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
|
||||
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { w: number; h: number }> } | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const resizingRef = useRef<{ id: string; dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
||||
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { x: number; y: number; w: number; h: number }> } | null>(null)
|
||||
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
|
||||
const rotateGroupRef = useRef<{ ids: string[]; orig: Record<string, { d: number }> } | null>(null)
|
||||
const [showGuides, setShowGuides] = useState(false)
|
||||
@@ -60,14 +58,20 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
return null
|
||||
}, [model, selectedId])
|
||||
|
||||
// enableGuides가 꺼지면 즉시 가이드를 숨긴다
|
||||
useEffect(() => {
|
||||
if (!enableGuides && showGuides) setShowGuides(false)
|
||||
if (!enableGuides && isDragging) setIsDragging(false)
|
||||
}, [enableGuides, showGuides, isDragging])
|
||||
|
||||
// a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림
|
||||
const ariaLiveText = useMemo(() => {
|
||||
const count = selectedIds.length
|
||||
const base = `선택 ${count}개`
|
||||
const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : ''
|
||||
const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
|
||||
const guide = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
|
||||
return `${base}${pos}${guide}`
|
||||
}, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y])
|
||||
}, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected])
|
||||
|
||||
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
|
||||
setModel((prev) => {
|
||||
@@ -107,14 +111,22 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
else if (e.key === 'ArrowDown') dy = step
|
||||
else return
|
||||
e.preventDefault()
|
||||
updateObject(selected.id, (o) => {
|
||||
const tx = o.x + dx
|
||||
const ty = o.y + dy
|
||||
const nx = useSnap ? snap8(tx) : tx
|
||||
const ny = useSnap ? snap8(ty) : ty
|
||||
return { ...o, x: nx, y: ny }
|
||||
})
|
||||
}, [selected, updateObject])
|
||||
|
||||
// 기준 객체(선택 anchor)에 대해 목표 좌표 계산 후 델타를 산출하여 멀티선택 전체에 동일 적용
|
||||
const tx = selected.x + dx
|
||||
const ty = selected.y + dy
|
||||
// 스냅은 이동한 축에만 적용, 이동이 없는 축은 값 유지
|
||||
const nx = dx !== 0 ? (useSnap ? snap8(tx) : tx) : selected.x
|
||||
const ny = dy !== 0 ? (useSnap ? snap8(ty) : ty) : selected.y
|
||||
const ddx = nx - selected.x
|
||||
const ddy = ny - selected.y
|
||||
|
||||
if (selectedIds.length > 1) {
|
||||
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy }))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy }))
|
||||
}
|
||||
}, [selected, selectedIds, updateObject, updateObjects])
|
||||
|
||||
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
if (e.shiftKey) {
|
||||
@@ -126,16 +138,28 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
// if already selected, preserve current multi-selection
|
||||
if (prev.includes(o.id)) {
|
||||
try { onSelectIds?.(prev) } catch {}
|
||||
return prev
|
||||
}
|
||||
const next = [o.id]
|
||||
setSelectedIds(next)
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
|
||||
setIsDragging(true)
|
||||
if (enableGuides) {
|
||||
setShowGuides(true)
|
||||
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
|
||||
}, [selectedIds, model.layers])
|
||||
}
|
||||
}, [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 +210,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 })
|
||||
if (selectedIds.length > 1) {
|
||||
const ddx = nx - d.origX
|
||||
const ddy = ny - d.origY
|
||||
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + ddx, y: o.y + ddy }))
|
||||
} else {
|
||||
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length])
|
||||
}
|
||||
}, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds, selectedIds.length, enableGuides, onSelectIds, showGuides])
|
||||
|
||||
const onMouseUpCanvas = useCallback(() => {
|
||||
if (marquee.active) {
|
||||
setMarquee((m) => ({ ...m, active: false }))
|
||||
}
|
||||
// 상호작용 종료 시 가이드 숨김
|
||||
if (showGuides) setShowGuides(false)
|
||||
setIsDragging(false)
|
||||
draggingRef.current = null
|
||||
resizingRef.current = null
|
||||
resizeGroupRef.current = null
|
||||
rotatingRef.current = null
|
||||
setShowGuides(false)
|
||||
}, [marquee.active])
|
||||
}, [marquee.active, showGuides])
|
||||
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => {
|
||||
e.stopPropagation()
|
||||
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
|
||||
resizingRef.current = { id: o.id, dir, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y, origW: o.width, origH: o.height }
|
||||
// capture originals for group resize
|
||||
const ids = Array.from(new Set([...selectedIds, o.id]))
|
||||
const orig: Record<string, { w: number; h: number }> = {}
|
||||
const orig: Record<string, { x: number; y: number; w: number; h: number }> = {}
|
||||
for (const ly of model.layers) {
|
||||
for (const ob of ly.objects) {
|
||||
if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height }
|
||||
if (ids.includes(ob.id)) orig[ob.id] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height }
|
||||
}
|
||||
}
|
||||
resizeGroupRef.current = { ids, orig }
|
||||
@@ -263,9 +434,11 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => {
|
||||
// 빈 캔버스 클릭 시 마퀴 시작 또는 선택 해제
|
||||
// 객체 onMouseDown에서 선택을 처리하므로, 여기서는 드래그 박스 시작만 담당
|
||||
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
|
||||
// 빈 영역(객체/핸들이 아닌 영역) 클릭 시 마퀴 시작 또는 선택 해제
|
||||
const t = e.target as HTMLElement
|
||||
const isObj = !!t.closest('[data-testid^="obj-"],[aria-label^="object "]')
|
||||
const isHandle = !!t.closest('[data-testid^="resize-handle-"]')
|
||||
if (!isObj && !isHandle) {
|
||||
setSelectedIds([])
|
||||
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
|
||||
try { onSelectIds?.([]) } catch {}
|
||||
@@ -276,7 +449,7 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
|
||||
data-testid="wysiwyg-canvas"
|
||||
>
|
||||
{showGuides && (
|
||||
{enableGuides && (showGuides || isDragging) && (
|
||||
<>
|
||||
<div data-testid="guide-x" style={{ position: 'absolute', left: guidePos.x, top: 0, bottom: 0, width: 1, background: '#0ea5e9' }} />
|
||||
<div data-testid="guide-y" style={{ position: 'absolute', top: guidePos.y, left: 0, right: 0, height: 1, background: '#0ea5e9' }} />
|
||||
@@ -316,12 +489,143 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
{o.props.label}
|
||||
</button>
|
||||
)}
|
||||
{o.type === 'input' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<input
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
placeholder={o.props.placeholder ?? ''}
|
||||
required={!!o.props.required}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
|
||||
/>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'select' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<select
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
value={o.props.defaultValue ?? ''}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', height: 28, padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb' }}
|
||||
onChange={() => { /* no-op in canvas preview */ }}
|
||||
>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'textarea' && (() => {
|
||||
const cid = `${o.id}-control`
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.label}</span>
|
||||
<textarea
|
||||
id={cid}
|
||||
name={o.props.name}
|
||||
placeholder={o.props.placeholder ?? ''}
|
||||
required={!!o.props.required}
|
||||
rows={o.props.rows ?? 3}
|
||||
aria-describedby={helpId || undefined}
|
||||
style={{ width: '100%', padding: '4px 8px', border: '1px solid #94a3b8', background: '#0f172a', color: '#f9fafb', resize: 'none' }}
|
||||
/>
|
||||
{o.props.help ? <small id={helpId} style={{ color: '#94a3b8', fontSize: 11 }}>{o.props.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
{o.type === 'radio' && (
|
||||
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
|
||||
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="radio" name={o.props.name} value={opt} checked={(o.props.defaultValue ?? '') === opt} onChange={() => {}} required={!!o.props.required} />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{o.type === 'checkbox' && (
|
||||
<fieldset style={{ display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid transparent' }}>
|
||||
<legend style={{ color: '#e5e7eb', fontSize: 12 }}>{o.props.legend}</legend>
|
||||
{(o.props.options ?? []).map((opt: string) => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" name={o.props.name} value={opt} checked={(o.props.defaultValues ?? []).includes(opt)} onChange={() => {}} required={!!o.props.required} />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{selectedIds.includes(o.id) && (
|
||||
<>
|
||||
{/* West (left-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-w-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'w')}
|
||||
style={{ position: 'absolute', left: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
|
||||
/>
|
||||
{/* North (top-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-n-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'n')}
|
||||
style={{ position: 'absolute', left: '50%', top: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
|
||||
/>
|
||||
{/* East (right-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-e-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'e')}
|
||||
style={{ position: 'absolute', right: -5, top: '50%', marginTop: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ew-resize' }}
|
||||
/>
|
||||
{/* South (bottom-middle) */}
|
||||
<div
|
||||
data-testid={`resize-handle-s-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 's')}
|
||||
style={{ position: 'absolute', left: '50%', bottom: -5, marginLeft: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'ns-resize' }}
|
||||
/>
|
||||
{/* North-East (top-right corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-ne-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'ne')}
|
||||
style={{ position: 'absolute', right: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
|
||||
/>
|
||||
{/* North-West (top-left corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-nw-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'nw')}
|
||||
style={{ position: 'absolute', left: -5, top: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
{/* South-East (bottom-right corner) - legacy testid alias */}
|
||||
<div
|
||||
data-testid={`resize-handle-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o)}
|
||||
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'se')}
|
||||
style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
<div
|
||||
data-testid={`resize-handle-se-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'se')}
|
||||
style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: 'transparent', cursor: 'nwse-resize' }}
|
||||
/>
|
||||
{/* South-West (bottom-left corner) */}
|
||||
<div
|
||||
data-testid={`resize-handle-sw-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownResize(e, o, 'sw')}
|
||||
style={{ position: 'absolute', left: -5, bottom: -5, width: 10, height: 10, background: '#0ea5e9', cursor: 'nesw-resize' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedIds.includes(o.id) && (
|
||||
<div
|
||||
@@ -335,16 +639,18 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
{/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */}
|
||||
<Moveable
|
||||
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
|
||||
draggable
|
||||
rotatable
|
||||
draggable={selectedIds.length === 1}
|
||||
rotatable={selectedIds.length === 1}
|
||||
snappable
|
||||
snapGridWidth={8}
|
||||
snapGridHeight={8}
|
||||
onDragStart={() => {
|
||||
// 드래그 시작 시 가이드 표시
|
||||
setShowGuides(true)
|
||||
if (resizingRef.current) return
|
||||
setIsDragging(true)
|
||||
if (enableGuides) setShowGuides(true)
|
||||
}}
|
||||
onDrag={({ left, top }) => {
|
||||
if (resizingRef.current) return
|
||||
if (!selected) return
|
||||
const rawLeftInput = typeof left === 'number' ? left : 0
|
||||
const rawTopInput = typeof top === 'number' ? top : 0
|
||||
@@ -465,10 +771,11 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
|
||||
}
|
||||
|
||||
// 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
|
||||
const guideX = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
|
||||
const guideY = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
|
||||
setGuidePos({ x: guideX, y: guideY })
|
||||
// 가이드 좌표 업데이트(에지 가이드 우선, 없으면 중심, 최후에는 nx/ny)
|
||||
const gx = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
|
||||
const gy = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
|
||||
if (enableGuides) setShowGuides(true)
|
||||
setGuidePos({ x: gx, y: gy })
|
||||
|
||||
if (selectedIds.length > 1) {
|
||||
const dx = nx - baseLeft
|
||||
@@ -479,14 +786,21 @@ export function Canvas({ frame, onChange, onSelectIds }: CanvasProps) {
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// 드래그 종료 시 가이드 숨김
|
||||
setIsDragging(false)
|
||||
setShowGuides(false)
|
||||
}}
|
||||
onRotate={({ rotate, inputEvent }) => {
|
||||
if (!selected) return
|
||||
const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey
|
||||
const nd = snapAngleUtil(typeof rotate === 'number' ? rotate : 0, fine)
|
||||
updateObject(selected.id, (o) => ({ ...o, rotate: nd }))
|
||||
const raw = typeof rotate === 'number' ? rotate : 0
|
||||
const shift = !!(inputEvent && (inputEvent as { shiftKey?: boolean }).shiftKey)
|
||||
const step = shift ? 1 : 15
|
||||
const snapped = Math.sign(raw) * Math.ceil(Math.abs(raw) / step) * step
|
||||
if (selectedIds.length > 1 && rotateGroupRef.current) {
|
||||
const { ids } = rotateGroupRef.current
|
||||
updateObjects(ids, (o) => ({ ...o, rotate: snapped }))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, rotate: snapped }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type LayersPanelProps = {
|
||||
@@ -9,6 +9,10 @@ export type LayersPanelProps = {
|
||||
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
|
||||
useEffect(() => {
|
||||
setModel(frame)
|
||||
}, [frame])
|
||||
|
||||
const move = useCallback((id: string, dir: 1 | -1) => {
|
||||
const idx = model.layers.findIndex((l) => l.id === id)
|
||||
if (idx < 0) return
|
||||
|
||||
@@ -23,6 +23,26 @@ function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> {
|
||||
return o.type === 'button'
|
||||
}
|
||||
|
||||
function isInput(o: WObject): o is Extract<WObject, { type: 'input' }> {
|
||||
return o.type === 'input'
|
||||
}
|
||||
|
||||
function isSelect(o: WObject): o is Extract<WObject, { type: 'select' }> {
|
||||
return o.type === 'select'
|
||||
}
|
||||
|
||||
function isRadio(o: WObject): o is Extract<WObject, { type: 'radio' }> {
|
||||
return o.type === 'radio'
|
||||
}
|
||||
|
||||
function isCheckbox(o: WObject): o is Extract<WObject, { type: 'checkbox' }> {
|
||||
return o.type === 'checkbox'
|
||||
}
|
||||
|
||||
function isTextarea(o: WObject): o is Extract<WObject, { type: 'textarea' }> {
|
||||
return o.type === 'textarea'
|
||||
}
|
||||
|
||||
export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
const [model, setModel] = useState<WObject>(object)
|
||||
|
||||
@@ -55,6 +75,36 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const updateInputProps = (patch: Partial<{ name: string; label: string; placeholder: string; required: boolean; help: string }>): void => {
|
||||
if (!isInput(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const updateSelectProps = (patch: Partial<{ name: string; label: string; options: string[]; defaultValue: string; help: string }>): void => {
|
||||
if (!isSelect(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const updateRadioProps = (patch: Partial<{ name: string; legend: string; options: string[]; defaultValue: string; required: boolean; help: string }>): void => {
|
||||
if (!isRadio(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const updateCheckboxProps = (patch: Partial<{ name: string; legend: string; options: string[]; defaultValues: string[]; required: boolean; help: string }>): void => {
|
||||
if (!isCheckbox(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const updateTextareaProps = (patch: Partial<{ name: string; label: string; placeholder: string; rows: number; required: boolean; help: string }>): void => {
|
||||
if (!isTextarea(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label="Properties Panel">
|
||||
<div>
|
||||
@@ -89,6 +139,118 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTextarea(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateTextareaProps({ name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateTextareaProps({ label: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Placeholder
|
||||
<input aria-label="Placeholder" type="text" value={model.props.placeholder || ''} onChange={(e) => updateTextareaProps({ placeholder: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Rows
|
||||
<input aria-label="Rows" type="number" value={model.props.rows ?? 3} onChange={(e) => updateTextareaProps({ rows: Number(e.target.value) || model.props.rows })} />
|
||||
</label>
|
||||
<label>
|
||||
Required
|
||||
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateTextareaProps({ required: e.target.checked })} />
|
||||
</label>
|
||||
<label>
|
||||
Help
|
||||
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateTextareaProps({ help: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRadio(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateRadioProps({ name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Legend
|
||||
<input aria-label="Legend" type="text" value={model.props.legend} onChange={(e) => updateRadioProps({ legend: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Options
|
||||
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateRadioProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
<label>
|
||||
Default
|
||||
<input aria-label="Default" type="text" value={model.props.defaultValue || ''} onChange={(e) => updateRadioProps({ defaultValue: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Required
|
||||
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateRadioProps({ required: e.target.checked })} />
|
||||
</label>
|
||||
<label>
|
||||
Help
|
||||
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateRadioProps({ help: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCheckbox(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateCheckboxProps({ name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Legend
|
||||
<input aria-label="Legend" type="text" value={model.props.legend} onChange={(e) => updateCheckboxProps({ legend: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Options
|
||||
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateCheckboxProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
<label>
|
||||
Defaults
|
||||
<input aria-label="Defaults" type="text" value={(model.props.defaultValues || []).join(',')} onChange={(e) => updateCheckboxProps({ defaultValues: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
<label>
|
||||
Required
|
||||
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateCheckboxProps({ required: e.target.checked })} />
|
||||
</label>
|
||||
<label>
|
||||
Help
|
||||
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateCheckboxProps({ help: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelect(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateSelectProps({ name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateSelectProps({ label: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Options
|
||||
<input aria-label="Options" type="text" value={(model.props.options || []).join(',')} onChange={(e) => updateSelectProps({ options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
<label>
|
||||
Default
|
||||
<input aria-label="Default" type="text" value={model.props.defaultValue || ''} onChange={(e) => updateSelectProps({ defaultValue: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Help
|
||||
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateSelectProps({ help: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isButton(model) && (
|
||||
<div>
|
||||
<label>
|
||||
@@ -110,6 +272,31 @@ export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInput(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Name
|
||||
<input aria-label="Name" type="text" value={model.props.name} onChange={(e) => updateInputProps({ name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateInputProps({ label: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Placeholder
|
||||
<input aria-label="Placeholder" type="text" value={model.props.placeholder || ''} onChange={(e) => updateInputProps({ placeholder: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Required
|
||||
<input aria-label="Required" type="checkbox" checked={!!model.props.required} onChange={(e) => updateInputProps({ required: e.target.checked })} />
|
||||
</label>
|
||||
<label>
|
||||
Help
|
||||
<input aria-label="Help" type="text" value={model.props.help || ''} onChange={(e) => updateInputProps({ help: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
const createObjectURL = global.URL.createObjectURL
|
||||
const revokeObjectURL = global.URL.revokeObjectURL
|
||||
let spyCreate: ReturnType<typeof vi.spyOn> | null = null
|
||||
let spyRevoke: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
describe('WYSIWYG Builder acceptance', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - test shim
|
||||
global.URL.createObjectURL = () => 'blob:mock'
|
||||
// @ts-expect-error - test shim
|
||||
global.URL.revokeObjectURL = () => {}
|
||||
spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
global.URL.createObjectURL = createObjectURL
|
||||
global.URL.revokeObjectURL = revokeObjectURL
|
||||
spyCreate?.mockRestore()
|
||||
spyRevoke?.mockRestore()
|
||||
spyCreate = null
|
||||
spyRevoke = null
|
||||
})
|
||||
|
||||
it('Add → Edit → Export smoke', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Text
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId('obj-text-1')
|
||||
|
||||
// Select and edit via Properties panel
|
||||
@@ -59,7 +59,7 @@ describe('WYSIWYG Builder acceptance', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Text and select
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj] = await screen.findAllByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj)
|
||||
@@ -85,7 +85,7 @@ describe('WYSIWYG Builder acceptance', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Text
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const [textObj] = await screen.findAllByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(textObj)
|
||||
const textInput = await screen.findByLabelText('Text')
|
||||
@@ -111,7 +111,7 @@ describe('WYSIWYG Builder acceptance', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 두 텍스트 추가
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('WYSIWYG Builder acceptance', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 텍스트 두 개 배치
|
||||
const addTextBtn = screen.getByRole('button', { name: /Add Text/i })
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Checkbox', () => {
|
||||
it('adds a Checkbox group, edits props, and reflects on Canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Checkbox
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Checkbox/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-checkbox-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
|
||||
const legendInput = panel.querySelector('input[aria-label="Legend"]') as HTMLInputElement
|
||||
const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
|
||||
const defaultsInput = panel.querySelector('input[aria-label="Defaults"]') as HTMLInputElement
|
||||
const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'agree' } })
|
||||
fireEvent.change(legendInput, { target: { value: 'Agreements' } })
|
||||
fireEvent.change(optionsInput, { target: { value: 'One,Two,Three' } })
|
||||
fireEvent.change(defaultsInput, { target: { value: 'Two,Three' } })
|
||||
fireEvent.click(requiredCheckbox)
|
||||
|
||||
const rendered = await screen.findByTestId(/obj-checkbox-/)
|
||||
expect(rendered).toHaveTextContent('Agreements')
|
||||
const inputs = Array.from(rendered.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[]
|
||||
expect(inputs.map(i => i.value)).toEqual(['One','Two','Three'])
|
||||
const checkedValues = inputs.filter(i => i.checked).map(i => i.value)
|
||||
expect(checkedValues).toEqual(['Two','Three'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Help (aria-describedby)', () => {
|
||||
it('Input: edits Help in Properties and Canvas reflects help + aria-describedby', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Input
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Input/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-input-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const helpInput = panel.querySelector('input[aria-label="Help"]') as HTMLInputElement
|
||||
fireEvent.change(helpInput, { target: { value: 'We will not spam you.' } })
|
||||
|
||||
const rendered = await screen.findByTestId(/obj-input-/)
|
||||
expect(rendered).toHaveTextContent('We will not spam you.')
|
||||
const inputEl = rendered.querySelector('input') as HTMLInputElement
|
||||
expect(inputEl).toBeTruthy()
|
||||
const describedby = inputEl.getAttribute('aria-describedby')
|
||||
expect(describedby).toMatch(/-help$/)
|
||||
const helpEl = rendered.querySelector(`#${describedby}`)
|
||||
expect(helpEl?.textContent).toContain('We will not spam you.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Input(Text)', () => {
|
||||
it('adds an Input, edits props in Properties, and reflects on Canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Input
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Input/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-input-/)
|
||||
// select to expose props
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
// edit props in Properties panel
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
|
||||
const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
|
||||
const placeholderInput = panel.querySelector('input[aria-label="Placeholder"]') as HTMLInputElement
|
||||
const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'email' } })
|
||||
fireEvent.change(labelInput, { target: { value: 'Email Address' } })
|
||||
fireEvent.change(placeholderInput, { target: { value: 'you@example.com' } })
|
||||
fireEvent.click(requiredCheckbox)
|
||||
|
||||
// Canvas should reflect label and placeholder
|
||||
const rendered = await screen.findByTestId(/obj-input-/)
|
||||
expect(rendered).toHaveTextContent('Email Address')
|
||||
const inputEl = rendered.querySelector('input') as HTMLInputElement
|
||||
expect(inputEl).toBeTruthy()
|
||||
expect(inputEl.placeholder).toBe('you@example.com')
|
||||
expect(inputEl.required).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Radio', () => {
|
||||
it('adds a Radio group, edits props, and reflects on Canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Radio
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Radio/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-radio-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
|
||||
const legendInput = panel.querySelector('input[aria-label="Legend"]') as HTMLInputElement
|
||||
const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
|
||||
const defaultInput = panel.querySelector('input[aria-label="Default"]') as HTMLInputElement
|
||||
const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'color' } })
|
||||
fireEvent.change(legendInput, { target: { value: 'Color' } })
|
||||
fireEvent.change(optionsInput, { target: { value: 'Red,Green,Blue' } })
|
||||
fireEvent.change(defaultInput, { target: { value: 'Green' } })
|
||||
fireEvent.click(requiredCheckbox)
|
||||
|
||||
const rendered = await screen.findByTestId(/obj-radio-/)
|
||||
expect(rendered).toHaveTextContent('Color')
|
||||
const inputs = Array.from(rendered.querySelectorAll('input[type="radio"]')) as HTMLInputElement[]
|
||||
expect(inputs.map(i => i.value)).toEqual(['Red','Green','Blue'])
|
||||
const checked = inputs.find(i => i.checked)
|
||||
expect(checked?.value).toBe('Green')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Select', () => {
|
||||
it('adds a Select, edits props, and reflects on Canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Select
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Select/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-select-/)
|
||||
// select to expose props
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
// edit props in Properties panel
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
|
||||
const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
|
||||
const optionsInput = panel.querySelector('input[aria-label="Options"]') as HTMLInputElement
|
||||
const defaultInput = panel.querySelector('input[aria-label="Default"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'size' } })
|
||||
fireEvent.change(labelInput, { target: { value: 'Size' } })
|
||||
fireEvent.change(optionsInput, { target: { value: 'S,M,L' } })
|
||||
fireEvent.change(defaultInput, { target: { value: 'M' } })
|
||||
|
||||
// Canvas should reflect label and select options/default
|
||||
const rendered = await screen.findByTestId(/obj-select-/)
|
||||
expect(rendered).toHaveTextContent('Size')
|
||||
const selectEl = rendered.querySelector('select') as HTMLSelectElement
|
||||
expect(selectEl).toBeTruthy()
|
||||
const opts = Array.from(selectEl.querySelectorAll('option')).map(o => o.textContent)
|
||||
expect(opts).toEqual(['S','M','L'])
|
||||
expect(selectEl.value).toBe('M')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Form Textarea', () => {
|
||||
it('adds a Textarea, edits props, and reflects on Canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Textarea
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Textarea/i }))
|
||||
|
||||
const obj = await screen.findByTestId(/obj-textarea-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const panel = await screen.findByLabelText('Properties Panel')
|
||||
const nameInput = panel.querySelector('input[aria-label="Name"]') as HTMLInputElement
|
||||
const labelInput = panel.querySelector('input[aria-label="Label"]') as HTMLInputElement
|
||||
const placeholderInput = panel.querySelector('input[aria-label="Placeholder"]') as HTMLInputElement
|
||||
const rowsInput = panel.querySelector('input[aria-label="Rows"]') as HTMLInputElement
|
||||
const requiredCheckbox = panel.querySelector('input[aria-label="Required"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'message' } })
|
||||
fireEvent.change(labelInput, { target: { value: 'Message' } })
|
||||
fireEvent.change(placeholderInput, { target: { value: 'Type here...' } })
|
||||
fireEvent.change(rowsInput, { target: { value: '5' } })
|
||||
fireEvent.click(requiredCheckbox)
|
||||
|
||||
const rendered = await screen.findByTestId(/obj-textarea-/)
|
||||
expect(rendered).toHaveTextContent('Message')
|
||||
const ta = rendered.querySelector('textarea') as HTMLTextAreaElement
|
||||
expect(ta).toBeTruthy()
|
||||
expect(ta.placeholder).toBe('Type here...')
|
||||
expect(ta.required).toBe(true)
|
||||
expect(ta.rows).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
function getXY(el: HTMLElement) {
|
||||
const style = el.getAttribute('style') || ''
|
||||
const m = { left: /left:\s*(\d+)px/, top: /top:\s*(\d+)px/ }
|
||||
const left = Number((style.match(m.left)?.[1]) || '0')
|
||||
const top = Number((style.match(m.top)?.[1]) || '0')
|
||||
return { left, top }
|
||||
}
|
||||
|
||||
function getRotate(el: HTMLElement) {
|
||||
const style = el.getAttribute('style') || ''
|
||||
const m = /rotate\(([-\d.]+)deg\)/
|
||||
const v = Number((style.match(m)?.[1]) || '0')
|
||||
return v
|
||||
}
|
||||
|
||||
describe('WYSIWYG - group actions drag/resize/rotate with snapping', () => {
|
||||
it('drags two selected objects together with 8px snapping', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
// add two texts
|
||||
const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
addText()
|
||||
addText()
|
||||
const objs = await screen.findAllByTestId(/obj-text-/)
|
||||
const a = objs[0]
|
||||
const b = objs[1]
|
||||
|
||||
// select both (click first then shift-click second)
|
||||
fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
|
||||
fireEvent.mouseUp(a)
|
||||
fireEvent.mouseDown(b, { clientX: 80, clientY: 80, shiftKey: true })
|
||||
// wait until selection reflects two items
|
||||
await screen.findByText(/선택 2개/)
|
||||
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
// drag second (selected) by ~13, expect snap to +16
|
||||
fireEvent.mouseDown(b, { clientX: 120, clientY: 120 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 133, clientY: 133 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
await waitFor(() => {
|
||||
const xyA = getXY(a)
|
||||
const xyB = getXY(b)
|
||||
expect(xyA.left).toBe(76)
|
||||
expect(xyA.top).toBe(76)
|
||||
expect(xyB.left).toBe(76)
|
||||
expect(xyB.top).toBe(76)
|
||||
})
|
||||
})
|
||||
|
||||
it('resizes group from east handle; both widths increase with snapping', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
addText()
|
||||
addText()
|
||||
const objs = await screen.findAllByTestId(/obj-text-/)
|
||||
const a = objs[0]
|
||||
const b = objs[1]
|
||||
|
||||
// select both
|
||||
fireEvent.mouseDown(a, { clientX: 60, clientY: 60 })
|
||||
fireEvent.mouseUp(a)
|
||||
fireEvent.mouseDown(b, { clientX: 80, clientY: 80, shiftKey: true })
|
||||
|
||||
// drag east handle of second by ~13 -> snap width +16
|
||||
const idB = (b.getAttribute('data-testid') || '').replace('obj-', '')
|
||||
const handleE = await screen.findByTestId(`resize-handle-e-${idB}`)
|
||||
fireEvent.mouseDown(handleE, { clientX: 180, clientY: 80 })
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseMove(canvas, { clientX: 193, clientY: 80 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
// widths reflected in inline style
|
||||
const styleA = a.getAttribute('style') || ''
|
||||
const styleB = b.getAttribute('style') || ''
|
||||
const wA = Number((styleA.match(/width:\s*(\d+)px/)?.[1]) || '0')
|
||||
const wB = Number((styleB.match(/width:\s*(\d+)px/)?.[1]) || '0')
|
||||
expect(wA).toBeGreaterThanOrEqual(176) // initial 160 -> +16
|
||||
expect(wB).toBeGreaterThanOrEqual(176)
|
||||
})
|
||||
|
||||
it('rotates group with 15deg snapping (no shift)', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const addText = () => fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
addText()
|
||||
addText()
|
||||
const objs = await screen.findAllByTestId(/obj-text-/)
|
||||
const a = objs[0]
|
||||
const b = objs[1]
|
||||
|
||||
// select both
|
||||
fireEvent.mouseDown(a)
|
||||
fireEvent.mouseUp(a)
|
||||
fireEvent.mouseDown(b, { shiftKey: true })
|
||||
fireEvent.mouseUp(b)
|
||||
|
||||
const idB2 = (b.getAttribute('data-testid') || '').replace('obj-', '')
|
||||
const rotateHandle = await screen.findByTestId(`rotate-handle-${idB2}`)
|
||||
fireEvent.mouseDown(rotateHandle, { clientX: 100, clientY: 10 })
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseMove(canvas, { clientX: 130, clientY: 10 }) // +30 -> snap 30 (15 step)
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const rA = getRotate(a)
|
||||
const rB = getRotate(b)
|
||||
expect(rA).toBe(30)
|
||||
expect(rB).toBe(30)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
describe('WYSIWYG Builder - Guides UI toggle', () => {
|
||||
it('shows guides by default, and hides when toggle is off', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// add a text and select it (exact match to avoid matching 'Add Textarea')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
|
||||
// default ON: start drag -> guides should appear
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(obj, { clientX: 120, clientY: 120 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 124, clientY: 124 })
|
||||
expect(await screen.findByTestId('guide-x')).toBeInTheDocument()
|
||||
|
||||
// end drag, then turn OFF and verify guides disappear on further movement
|
||||
const btn = screen.getByRole('button', { name: /Guides/i })
|
||||
fireEvent.mouseUp(canvas)
|
||||
fireEvent.click(btn)
|
||||
fireEvent.mouseMove(canvas, { clientX: 132, clientY: 132 })
|
||||
expect(screen.queryByTestId('guide-x')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@ describe('WYSIWYG Builder - Layers acceptance', () => {
|
||||
it('visible toggle reflects on layer button state', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
// add two texts
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Text/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
|
||||
// 레이어 패널에서 visible 토글 버튼 상태만 검증
|
||||
const hideBtn = screen.getByTestId('layer-hide-base')
|
||||
@@ -18,4 +18,30 @@ describe('WYSIWYG Builder - Layers acceptance', () => {
|
||||
fireEvent.click(hideBtn)
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('layer order changes reflect in Canvas DOM stacking order', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
// Add two layers, each with one text object labeled L1, L2
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Layer/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Layer/i }))
|
||||
|
||||
const t1 = await screen.findByText('L1')
|
||||
const t2 = await screen.findByText('L2')
|
||||
|
||||
// Initially, later layer (L2) should be on top, thus follow L1 in DOM order
|
||||
const pos1 = t1.compareDocumentPosition(t2)
|
||||
expect(pos1 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// Move L2 up (towards front doesn't change since it's already last), then move L2 down to go below L1
|
||||
const up2 = screen.getByTestId('layer-up-layer-2')
|
||||
const down2 = screen.getByTestId('layer-down-layer-2')
|
||||
// up no-op when at top boundary (safe click)
|
||||
fireEvent.click(up2)
|
||||
// now move it down once (below)
|
||||
fireEvent.click(down2)
|
||||
|
||||
// After moving L2 down, L1 should now come after L2 in DOM order (L1 on top)
|
||||
const pos2 = t2.compareDocumentPosition(t1)
|
||||
expect(pos2 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
function getBox() {
|
||||
const el = screen.getByTestId(/obj-text-/) as HTMLElement
|
||||
const style = el.style
|
||||
const left = parseInt(style.left || '0', 10)
|
||||
const top = parseInt(style.top || '0', 10)
|
||||
const width = parseInt(style.width || '0', 10)
|
||||
const height = parseInt(style.height || '0', 10)
|
||||
return { left, top, width, height }
|
||||
|
||||
}
|
||||
|
||||
function snap8(v: number) {
|
||||
return Math.round(v / 8) * 8
|
||||
}
|
||||
|
||||
describe('WYSIWYG Builder - Resize directional handles', () => {
|
||||
it('left handle resizes by moving left edge and shifts x', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
// select
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
// end any potential drag
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const before = getBox()
|
||||
const hLeft = await screen.findByTestId(/resize-handle-w-/)
|
||||
// drag left handle to the left by 16px (two 8px snaps)
|
||||
fireEvent.mouseDown(hLeft, { clientX: before.left, clientY: before.top + 5 })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top + 5 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
const expectedLeft = Math.min(snap8(before.left - 16), before.left + before.width - 8)
|
||||
const expectedWidth = (before.left + before.width) - expectedLeft
|
||||
expect(after.left).toBe(expectedLeft)
|
||||
expect(after.width).toBe(expectedWidth)
|
||||
// orthogonal axis may adjust minimally due to snapping/rounding; primary check is x/width
|
||||
})
|
||||
|
||||
it('corner NW handle moves left/top and expands width/height', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const before = getBox()
|
||||
const hNW = await screen.findByTestId(/resize-handle-nw-/)
|
||||
fireEvent.mouseDown(hNW, { clientX: before.left, clientY: before.top })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top - 16 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
const expLeft = Math.min(snap8(before.left - 16), before.left + before.width - 8)
|
||||
const expTop = Math.min(snap8(before.top - 16), before.top + before.height - 8)
|
||||
expect(after.left).toBe(expLeft)
|
||||
expect(after.top).toBe(expTop)
|
||||
expect(after.width).toBe((before.left + before.width) - expLeft)
|
||||
expect(after.height).toBe((before.top + before.height) - expTop)
|
||||
})
|
||||
|
||||
it('corner SW handle moves left and expands height with bottom drag', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const before = getBox()
|
||||
const hSW = await screen.findByTestId(/resize-handle-sw-/)
|
||||
fireEvent.mouseDown(hSW, { clientX: before.left, clientY: before.top + before.height })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left - 16, clientY: before.top + before.height + 16 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
const expLeft2 = Math.min(snap8(before.left - 16), before.left + before.width - 8)
|
||||
expect(after.left).toBe(expLeft2)
|
||||
expect(after.width).toBe((before.left + before.width) - expLeft2)
|
||||
// bottom 증가, top은 유지
|
||||
expect(after.top).toBe(before.top)
|
||||
expect(after.height).toBe(snap8(before.height + 16))
|
||||
})
|
||||
|
||||
it('top handle resizes by moving top edge and shifts y', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const before = getBox()
|
||||
const hTop = await screen.findByTestId(/resize-handle-n-/)
|
||||
fireEvent.mouseDown(hTop, { clientX: before.left + 5, clientY: before.top })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + 5, clientY: before.top - 16 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
const expectedTop = Math.min(snap8(before.top - 16), before.top + before.height - 8)
|
||||
const expectedHeight = (before.top + before.height) - expectedTop
|
||||
expect(after.top).toBe(expectedTop)
|
||||
expect(after.height).toBe(expectedHeight)
|
||||
// orthogonal axis may adjust minimally due to snapping; primary check is y/height
|
||||
})
|
||||
|
||||
it('right and bottom handles increase width/height without shifting origin', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
|
||||
const before = getBox()
|
||||
const hRight = await screen.findByTestId(/resize-handle-e-/)
|
||||
fireEvent.mouseDown(hRight, { clientX: before.left + before.width, clientY: before.top + 5 })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + before.width + 16, clientY: before.top + 5 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const mid = getBox()
|
||||
// primary check: width increased by snapped delta
|
||||
expect(mid.width).toBe(snap8(before.width + 16))
|
||||
|
||||
const hBottom = await screen.findByTestId(/resize-handle-s-/)
|
||||
fireEvent.mouseDown(hBottom, { clientX: mid.left + 5, clientY: mid.top + mid.height })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: mid.left + 5, clientY: mid.top + mid.height + 16 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
// origin should remain same on y for bottom handle
|
||||
expect(after.top).toBe(mid.top)
|
||||
expect(after.height).toBe(snap8(mid.height + 16))
|
||||
})
|
||||
|
||||
it('corner NE handle adjusts width and height with appropriate origin shift on Y', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Text$/i }))
|
||||
const obj = await screen.findByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj, { clientX: 80, clientY: 80 })
|
||||
|
||||
const before = getBox()
|
||||
const hNE = await screen.findByTestId(/resize-handle-ne-/)
|
||||
fireEvent.mouseDown(hNE, { clientX: before.left + before.width, clientY: before.top })
|
||||
fireEvent.mouseMove(screen.getByTestId('wysiwyg-canvas'), { clientX: before.left + before.width + 16, clientY: before.top - 16 })
|
||||
fireEvent.mouseUp(screen.getByTestId('wysiwyg-canvas'))
|
||||
|
||||
const after = getBox()
|
||||
// NE: primary checks are top shifted up and width increased; left origin may adjust under snapping
|
||||
const expectedTop2 = Math.min(snap8(before.top - 16), before.top + before.height - 8)
|
||||
expect(after.top).toBe(expectedTop2)
|
||||
expect(after.width).toBe(snap8(before.width + 16))
|
||||
expect(after.height).toBe((before.top + before.height) - expectedTop2)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
// minimal mock for URL.createObjectURL used by download
|
||||
const createObjectURL = global.URL.createObjectURL
|
||||
const revokeObjectURL = global.URL.revokeObjectURL
|
||||
let spyCreate: ReturnType<typeof vi.spyOn> | null = null
|
||||
let spyRevoke: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
describe('WysiwygBuilderPage', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
global.URL.createObjectURL = () => 'blob:mock'
|
||||
// @ts-ignore
|
||||
global.URL.revokeObjectURL = () => {}
|
||||
spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
global.URL.createObjectURL = createObjectURL
|
||||
global.URL.revokeObjectURL = revokeObjectURL
|
||||
spyCreate?.mockRestore()
|
||||
spyRevoke?.mockRestore()
|
||||
spyCreate = null
|
||||
spyRevoke = null
|
||||
})
|
||||
|
||||
it('adds a text object to the canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
const addText = screen.getByRole('button', { name: /Add Text/i })
|
||||
const addText = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addText)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('obj-text-1')).toBeInTheDocument()
|
||||
|
||||
@@ -39,11 +39,18 @@ function download(filename: string, data: Uint8Array) {
|
||||
let textCounter = 1
|
||||
let imageCounter = 1
|
||||
let buttonCounter = 1
|
||||
let inputCounter = 1
|
||||
let selectCounter = 1
|
||||
let textareaCounter = 1
|
||||
let radioCounter = 1
|
||||
let checkboxCounter = 1
|
||||
let layerCounter = 1
|
||||
|
||||
export function WysiwygBuilderPage() {
|
||||
const [frame, setFrame] = useState<Frame>(() => emptyFrame())
|
||||
const assetManagerRef = useRef<AssetManager | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [guidesOn, setGuidesOn] = useState(true)
|
||||
const selectedId = selectedIds[selectedIds.length - 1] || null
|
||||
const selectedObject: WObject | null = useMemo(() => {
|
||||
for (const ly of frame.layers) {
|
||||
@@ -66,6 +73,62 @@ export function WysiwygBuilderPage() {
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addRadio = useCallback(() => {
|
||||
const id = `radio-${radioCounter++}`
|
||||
const obj: WObject = { id, type: 'radio', x: 60, y: 240, width: 300, height: 96, rotate: 0, zIndex: 0, props: { name: 'choice', legend: 'Legend', options: ['A','B','C'], defaultValue: 'A', required: false } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addCheckbox = useCallback(() => {
|
||||
const id = `checkbox-${checkboxCounter++}`
|
||||
const obj: WObject = { id, type: 'checkbox', x: 60, y: 360, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'agree', legend: 'Legend', options: ['One','Two','Three'], defaultValues: ['One'], required: false } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addTextarea = useCallback(() => {
|
||||
const id = `textarea-${textareaCounter++}`
|
||||
const obj: WObject = { id, type: 'textarea', x: 60, y: 180, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'message', label: 'Message', placeholder: '', rows: 3, required: false } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addSelect = useCallback(() => {
|
||||
const id = `select-${selectCounter++}`
|
||||
const obj: WObject = { id, type: 'select', x: 60, y: 120, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', options: ['S','M','L'], defaultValue: '' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addLayer = useCallback(() => {
|
||||
const lid = `layer-${layerCounter++}`
|
||||
const tid = `text-${textCounter++}`
|
||||
const layer: Frame['layers'][number] = {
|
||||
id: lid,
|
||||
name: `Layer ${layerCounter - 1}`,
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: tid, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: `L${layerCounter - 1}`, fontSize: 18, color: '#ffffff' } },
|
||||
],
|
||||
}
|
||||
setFrame((prev) => ({ ...prev, layers: [...prev.layers, layer] }))
|
||||
setSelectedIds([tid])
|
||||
}, [])
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const id = `image-${imageCounter++}`
|
||||
const obj: WObject = { id, type: 'image', x: 60, y: 120, width: 120, height: 90, rotate: 0, zIndex: 0, props: { src: 'https://via.placeholder.com/120x90', alt: '' } }
|
||||
@@ -122,6 +185,16 @@ export function WysiwygBuilderPage() {
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const addInput = useCallback(() => {
|
||||
const id = `input-${inputCounter++}`
|
||||
const obj: WObject = { id, type: 'input', x: 60, y: 60, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', placeholder: '', required: false } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const onChangeObject = useCallback((next: WObject) => {
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
@@ -152,6 +225,7 @@ export function WysiwygBuilderPage() {
|
||||
<div className="flex gap-2 items-center">
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addText}>Add Text</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addImage}>Add Image</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addLayer}>Add Layer</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp,image/svg+xml"
|
||||
@@ -160,6 +234,11 @@ export function WysiwygBuilderPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addInput}>Add Input</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addSelect}>Add Select</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addTextarea}>Add Textarea</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addRadio}>Add Radio</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addCheckbox}>Add Checkbox</button>
|
||||
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addButton}>Add Button</button>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
@@ -169,10 +248,20 @@ export function WysiwygBuilderPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">WYSIWYG Builder</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={guidesOn ? 'true' : 'false'}
|
||||
className="bg-gray-200 rounded px-3 py-2"
|
||||
onClick={() => setGuidesOn((v) => !v)}
|
||||
>
|
||||
Guides
|
||||
</button>
|
||||
<button type="button" className="bg-emerald-600 text-white rounded px-4 py-2" onClick={doExport}>Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-2 inline-block" style={{ background: '#111111' }}>
|
||||
<Canvas frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} />
|
||||
<Canvas key={`guides-${guidesOn ? 'on' : 'off'}`} frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} enableGuides={guidesOn} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import type { ExportOptions } from '@/lib/exporter/html'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Img Matrix',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } },
|
||||
],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
seo: { title: 'T', description: 'D' },
|
||||
}
|
||||
|
||||
describe('image responsive options - matrix', () => {
|
||||
it('applies loading and decoding attributes from options', () => {
|
||||
const opts: ExportOptions = { imageLoading: 'eager', imageDecoding: 'sync' }
|
||||
const { html } = exportPage(basePage, opts)
|
||||
expect(html).toMatch(/<img[^>]*loading="eager"/)
|
||||
expect(html).toMatch(/<img[^>]*decoding="sync"/)
|
||||
})
|
||||
|
||||
it('sets fetchpriority="low" when specified', () => {
|
||||
const opts: ExportOptions = { imageFetchPriority: 'low' }
|
||||
const { html } = exportPage(basePage, opts)
|
||||
expect(html).toMatch(/<img[^>]*fetchpriority="low"/)
|
||||
})
|
||||
|
||||
it('prefers preset sizes over auto default when both provided (no explicit sizes)', () => {
|
||||
const opts: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
imageSizesPreset: 'mobile-first',
|
||||
}
|
||||
const { html } = exportPage(basePage, opts)
|
||||
// preset should win over default auto sizes
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 800px"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(bg = '#111111'): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: bg,
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - CSS regression (tokens + rules presence)', () => {
|
||||
it('contains core tokens and rules across themes and states', () => {
|
||||
const out = exportFrame(baseFrame('#222222'))
|
||||
const css = out.css
|
||||
// root and theme tokens
|
||||
expect(css).toMatch(/:root\s*{[^}]*--bg:/)
|
||||
expect(css).toMatch(/\[data-theme="light"\]\s*{[^}]*--bg:/)
|
||||
expect(css).toMatch(/\[data-theme="dark"\]\s*{[^}]*--bg:/)
|
||||
// base usage
|
||||
expect(css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
|
||||
expect(css).toMatch(/a\s*{[^}]*color:var\(--primary\)/)
|
||||
// controls base
|
||||
expect(css).toMatch(/input,select,textarea\s*{[^}]*color:var\(--text\)/)
|
||||
expect(css).toMatch(/input,select,textarea\s*{[^}]*border:1px solid var\(--border\)/)
|
||||
// states
|
||||
expect(css).toMatch(/input:focus,select:focus,textarea:focus\s*{[^}]*outline:2px solid var\(--primary\)/)
|
||||
expect(css).toMatch(/:focus-visible\s*{[^}]*outline:2px solid var\(--ring\)/)
|
||||
expect(css).toMatch(/:focus-visible\s*{[^}]*outline-offset:var\(--ring-offset\)/)
|
||||
expect(css).toMatch(/input:invalid,select:invalid,textarea:invalid\s*{[^}]*border-color:var\(--danger\)/)
|
||||
expect(css).toMatch(/input:disabled,select:disabled,textarea:disabled\s*{[^}]*background:var\(--disabled-bg\)/)
|
||||
expect(css).toMatch(/input\[readonly\],select\[readonly\],textarea\[readonly\]\s*{[^}]*color:var\(--disabled-fg\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function frameWithForm(): Frame {
|
||||
return {
|
||||
id: 'f', width: 600, height: 400, background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'base', name: 'Base', visible: true, locked: false, objects: [
|
||||
{ id: 't1', type: 'input', x: 40, y: 40, width: 200, height: 32, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: '', required: true, help: 'We never share your email.' } },
|
||||
{ id: 't2', type: 'select', x: 40, y: 100, width: 200, height: 32, rotate: 0, zIndex: 0, props: { name: 'country', label: 'Country', options: ['KR','US'], defaultValue: 'KR', help: 'Pick one' } },
|
||||
{ id: 't3', type: 'textarea', x: 40, y: 160, width: 280, height: 80, rotate: 0, zIndex: 0, props: { name: 'msg', label: 'Message', placeholder: '', rows: 3, required: false, help: 'Optional' } },
|
||||
{ id: 't4', type: 'radio', x: 320, y: 40, width: 200, height: 90, rotate: 0, zIndex: 0, props: { name: 'plan', legend: 'Plan', options: ['Basic','Pro'], defaultValue: 'Basic', required: false, help: 'Choose your plan' } },
|
||||
{ id: 't5', type: 'checkbox', x: 320, y: 150, width: 200, height: 60, rotate: 0, zIndex: 0, props: { name: 'agree', legend: 'Agree to terms', options: ['Yes'], defaultValues: [], required: false, help: 'You must agree' } },
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - a11y matrix for form controls', () => {
|
||||
it('label/for + aria-describedby for input/select/textarea', () => {
|
||||
const out = exportFrame(frameWithForm())
|
||||
const html = out.html
|
||||
// input
|
||||
expect(html).toMatch(/<label for="t1-control">Email<\/label>/)
|
||||
expect(html).toMatch(/<input[^>]*id="t1-control"[^>]*name="email"[^>]*required/)
|
||||
expect(html).toMatch(/<small id="t1-control-help">We never share your email\.<\/small>/)
|
||||
expect(html).toMatch(/<input[^>]*aria-describedby="t1-control-help"/)
|
||||
// select
|
||||
expect(html).toMatch(/<label for="t2-control">Country<\/label>/)
|
||||
expect(html).toMatch(/<select[^>]*id="t2-control"[^>]*name="country"[^>]*aria-describedby="t2-control-help"/)
|
||||
expect(html).toMatch(/<small id="t2-control-help">Pick one<\/small>/)
|
||||
// textarea
|
||||
expect(html).toMatch(/<label for="t3-control">Message<\/label>/)
|
||||
expect(html).toMatch(/<textarea[^>]*id="t3-control"[^>]*name="msg"[^>]*aria-describedby="t3-control-help"/)
|
||||
expect(html).toMatch(/<small id="t3-control-help">Optional<\/small>/)
|
||||
})
|
||||
|
||||
it('fieldset/legend present for radio group with help items', () => {
|
||||
const out = exportFrame(frameWithForm())
|
||||
const html = out.html
|
||||
// fieldset/legend exist (current implementation does not add aria-describedby on fieldset)
|
||||
expect(html).toMatch(/<fieldset id="t4">/)
|
||||
expect(html).toMatch(/<legend>Plan<\/legend>/)
|
||||
// individual radio inputs share name and carry required if set on group (not set here), and pattern/max if present
|
||||
expect(html).toMatch(/<input type="radio" id="t4-0" name="plan" value="Basic" checked/)
|
||||
expect(html).toMatch(/<label for="t4-0">Basic<\/label>/)
|
||||
expect(html).toMatch(/<input type="radio" id="t4-1" name="plan" value="Pro"/)
|
||||
expect(html).toMatch(/<label for="t4-1">Pro<\/label>/)
|
||||
// help element exists (small)
|
||||
expect(html).toMatch(/<small id="t4-help">Choose your plan<\/small>/)
|
||||
})
|
||||
|
||||
it('fieldset/legend present for checkbox group with help items', () => {
|
||||
const out = exportFrame(frameWithForm())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<fieldset id="t5">/)
|
||||
expect(html).toMatch(/<legend>Agree to terms<\/legend>/)
|
||||
expect(html).toMatch(/<input type="checkbox" id="t5-0" name="agree" value="Yes"/)
|
||||
expect(html).toMatch(/<label for="t5-0">Yes<\/label>/)
|
||||
expect(html).toMatch(/<small id="t5-help">You must agree<\/small>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 400, height: 300, background: '#ffffff',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - forms aria-describedby(help)', () => {
|
||||
it('input: adds help element and aria-describedby linkage', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 10, y: 20, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: '', required: false, help: 'We will not spam you.' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<div id="in1">[\s\S]*<input id="in1-control" name="email" aria-describedby="in1-control-help"\/>[\s\S]*<small id="in1-control-help"/)
|
||||
expect(out.html).toMatch(/We will not spam you\./)
|
||||
})
|
||||
|
||||
it('select: adds help and aria-describedby', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'sel1', type: 'select', x: 0, y: 0, width: 100, height: 30, rotate: 0, zIndex: 0, props: { name: 'size', label: 'Size', options: ['S'], defaultValue: 'S', help: 'Choose wisely.' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<select id="sel1-control" name="size" aria-describedby="sel1-control-help">/)
|
||||
expect(out.html).toMatch(/<small id="sel1-control-help"/)
|
||||
})
|
||||
|
||||
it('textarea: adds help and aria-describedby', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'msg', label: 'Msg', placeholder: '', rows: 3, required: false, help: 'Say hello.' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg" rows="3" aria-describedby="ta1-control-help"><\/textarea>/)
|
||||
expect(out.html).toMatch(/<small id="ta1-control-help"/)
|
||||
})
|
||||
|
||||
it('radio: adds group help (fieldset) and does not use aria-describedby per item by default', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'rg1', type: 'radio', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'color', legend: 'Color', options: ['Red'], defaultValue: 'Red', required: false, help: 'Pick one.' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<fieldset id="rg1">[\s\S]*<legend>Color<\/legend>[\s\S]*<small id="rg1-help"/)
|
||||
})
|
||||
|
||||
it('checkbox: adds group help (fieldset)', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'cb1', type: 'checkbox', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'pick', legend: 'Pick', options: ['One'], defaultValues: [], required: false, help: 'You can pick many.' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<fieldset id="cb1">[\s\S]*<legend>Pick<\/legend>[\s\S]*<small id="cb1-help"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 400, height: 300, background: '#ffffff',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - forms a11y', () => {
|
||||
it('exports input with label/for and required/placeholder', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 10, y: 20, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: 'you@example.com', required: true } })
|
||||
const out = exportFrame(f)
|
||||
// label and input id/for
|
||||
expect(out.html).toMatch(/<label for="in1-control">Email<\/label>/)
|
||||
expect(out.html).toMatch(/<input id="in1-control" name="email"[^>]*placeholder="you@example.com"[^>]*required/)
|
||||
// absolutely positioned wrapper
|
||||
expect(out.css).toMatch(/#in1\s*{[^}]*left:10px;[^}]*top:20px;[^}]*width:260px;[^}]*height:48px/)
|
||||
})
|
||||
|
||||
it('exports select with options and defaultValue', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'sel1', type: 'select', x: 12, y: 30, width: 220, height: 40, rotate: 0, zIndex: 0, props: { name: 'size', label: 'Size', options: ['S','M','L'], defaultValue: 'M' } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<label for="sel1-control">Size<\/label>/)
|
||||
expect(out.html).toMatch(/<select id="sel1-control" name="size"/)
|
||||
expect(out.html).toMatch(/<option value="S">S<\/option>/)
|
||||
expect(out.html).toMatch(/<option value="M" selected>M<\/option>/)
|
||||
expect(out.html).toMatch(/<option value="L">L<\/option>/)
|
||||
})
|
||||
|
||||
it('exports textarea with label and rows/placeholder/required', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 14, y: 40, width: 300, height: 90, rotate: 0, zIndex: 0, props: { name: 'message', label: 'Message', placeholder: 'Type...', rows: 5, required: true } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<label for="ta1-control">Message<\/label>/)
|
||||
expect(out.html).toMatch(/<textarea id="ta1-control" name="message"[^>]*rows="5"[^>]*placeholder="Type..."[^>]*required/)
|
||||
})
|
||||
|
||||
it('exports radio group with fieldset/legend and checked default', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'rg1', type: 'radio', x: 16, y: 60, width: 300, height: 80, rotate: 0, zIndex: 0, props: { name: 'color', legend: 'Color', options: ['Red','Green','Blue'], defaultValue: 'Green', required: true } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<fieldset id="rg1"/)
|
||||
expect(out.html).toMatch(/<legend>Color<\/legend>/)
|
||||
expect(out.html).toMatch(/<input type="radio" id="rg1-1" name="color" value="Green" checked required\/>/)
|
||||
expect(out.html).toMatch(/<label for="rg1-1">Green<\/label>/)
|
||||
})
|
||||
|
||||
it('exports checkbox group with fieldset/legend and defaultValues', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'cb1', type: 'checkbox', x: 18, y: 80, width: 320, height: 100, rotate: 0, zIndex: 0, props: { name: 'agreements', legend: 'Agree', options: ['One','Two','Three'], defaultValues: ['Two','Three'], required: true } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<fieldset id="cb1"/)
|
||||
expect(out.html).toMatch(/<legend>Agree<\/legend>/)
|
||||
expect(out.html).toMatch(/<input type="checkbox" id="cb1-1" name="agreements" value="Two" checked required\/>/)
|
||||
expect(out.html).toMatch(/<label for="cb1-1">Two<\/label>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 400, height: 300, background: '#ffffff',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - form validation attributes', () => {
|
||||
it('input: pattern, minLength, maxLength exported', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 0, y: 0, width: 200, height: 40, rotate: 0, zIndex: 0, props: { name: 'code', label: 'Code', placeholder: '', required: false, pattern: '[A-Z]{3}', minLength: 3, maxLength: 3 } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*pattern="\[A-Z\]\{3\}"/)
|
||||
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*minlength="3"/i)
|
||||
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*maxlength="3"/i)
|
||||
})
|
||||
|
||||
it('textarea: minLength, maxLength exported', () => {
|
||||
const f = baseFrame()
|
||||
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 0, y: 0, width: 200, height: 80, rotate: 0, zIndex: 0, props: { name: 'msg', label: 'Msg', placeholder: '', rows: 4, required: false, minLength: 10, maxLength: 200 } })
|
||||
const out = exportFrame(f)
|
||||
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg"[^>]*minlength="10"/i)
|
||||
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg"[^>]*maxlength="200"/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - style tokens for controls', () => {
|
||||
it('defines base styles for inputs/selects/textarea/fieldset/legend using tokens', () => {
|
||||
const f = baseFrame()
|
||||
const out = exportFrame(f)
|
||||
expect(out.css).toMatch(/input,select,textarea\s*{[^}]*color:var\(--text\)/)
|
||||
expect(out.css).toMatch(/input,select,textarea\s*{[^}]*border:1px solid var\(--border\)/)
|
||||
expect(out.css).toMatch(/fieldset\s*{[^}]*border:1px solid var\(--border\)/)
|
||||
expect(out.css).toMatch(/legend\s*{[^}]*color:var\(--text\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - disabled/read-only control tokens', () => {
|
||||
it('defines disabled tokens and uses them for :disabled and [readonly]', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
// tokens
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--disabled-bg:/)
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--disabled-fg:/)
|
||||
// usage
|
||||
expect(out.css).toMatch(/input:disabled,select:disabled,textarea:disabled\s*{[^}]*background:var\(--disabled-bg\)/)
|
||||
expect(out.css).toMatch(/input:disabled,select:disabled,textarea:disabled\s*{[^}]*color:var\(--disabled-fg\)/)
|
||||
expect(out.css).toMatch(/input\[readonly\],select\[readonly\],textarea\[readonly\]\s*{[^}]*color:var\(--disabled-fg\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - focus-visible ring tokens', () => {
|
||||
it('defines ring tokens and uses them in :focus-visible styles', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
// tokens
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--ring:/)
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--ring-offset:/)
|
||||
// usage (focus-visible across common focusables and controls)
|
||||
expect(out.css).toMatch(/:focus-visible\s*{[^}]*outline:2px solid var\(--ring\)/)
|
||||
expect(out.css).toMatch(/:focus-visible\s*{[^}]*outline-offset:var\(--ring-offset\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 400, height: 300, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - form tokens deep regression', () => {
|
||||
it('contains ring/disabled tokens and applies across selectors', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
const css = out.css
|
||||
// tokens
|
||||
expect(css).toMatch(/:root[^}]*--ring:/)
|
||||
expect(css).toMatch(/:root[^}]*--ring-offset:/)
|
||||
expect(css).toMatch(/:root[^}]*--disabled-bg:/)
|
||||
expect(css).toMatch(/:root[^}]*--disabled-fg:/)
|
||||
// usages
|
||||
expect(css).toMatch(/:focus-visible\s*{[^}]*outline:2px solid var\(--ring\)/)
|
||||
expect(css).toMatch(/:focus-visible\s*{[^}]*outline-offset:var\(--ring-offset\)/)
|
||||
expect(css).toMatch(/input:disabled,select:disabled,textarea:disabled\s*{[^}]*background:var\(--disabled-bg\)/)
|
||||
expect(css).toMatch(/input\[readonly\],select\[readonly\],textarea\[readonly\]\s*{[^}]*color:var\(--disabled-fg\)/)
|
||||
})
|
||||
|
||||
it('themes define overrides without breaking base var() usage', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
const css = out.css
|
||||
expect(css).toMatch(/\[data-theme="light"\][^{]*{[^}]*--bg:/)
|
||||
expect(css).toMatch(/\[data-theme="dark"\][^{]*{[^}]*--bg:/)
|
||||
expect(css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - control state styles via tokens', () => {
|
||||
it('adds :focus styles using tokens', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
expect(out.css).toMatch(/:root[^}]*--primary:/)
|
||||
expect(out.css).toMatch(/input:focus,select:focus,textarea:focus\s*{[^}]*outline:2px solid var\(--primary\)/)
|
||||
})
|
||||
it('adds :invalid styles using tokens', () => {
|
||||
const out = exportFrame(baseFrame())
|
||||
expect(out.css).toMatch(/:root[^}]*--danger:/)
|
||||
expect(out.css).toMatch(/input:invalid,select:invalid,textarea:invalid\s*{[^}]*border-color:var\(--danger\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: '#111111',
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - style tokens', () => {
|
||||
it('injects :root tokens and uses them in base rules', () => {
|
||||
const f = baseFrame()
|
||||
const out = exportFrame(f)
|
||||
// root tokens
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--bg:\s*#[0-9a-fA-F]{3,6}/)
|
||||
expect(out.css).toMatch(/--text:/)
|
||||
expect(out.css).toMatch(/--border:/)
|
||||
expect(out.css).toMatch(/--primary:/)
|
||||
// frame uses tokens
|
||||
expect(out.css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
|
||||
// anchor uses primary color
|
||||
expect(out.css).toMatch(/a\s*{[^}]*color:var\(--primary\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function baseFrame(bg = '#111111'): Frame {
|
||||
return {
|
||||
id: 'f', width: 320, height: 200, background: bg,
|
||||
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export - theme switching tokens', () => {
|
||||
it('emits light/dark token presets', () => {
|
||||
const out = exportFrame(baseFrame('#202020'))
|
||||
expect(out.css).toMatch(/:root\s*{[^}]*--bg:/)
|
||||
expect(out.css).toMatch(/\[data-theme="light"\]\s*{[^}]*--bg:/)
|
||||
expect(out.css).toMatch(/\[data-theme="dark"\]\s*{[^}]*--bg:/)
|
||||
})
|
||||
it('frame uses var(--bg), so theme can override background', () => {
|
||||
const out = exportFrame(baseFrame('#333333'))
|
||||
expect(out.css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
|
||||
})
|
||||
})
|
||||
+94
-2
@@ -9,6 +9,11 @@ function esc(s: string) {
|
||||
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> { return o.type === 'image' }
|
||||
function isText(o: WObject): o is Extract<WObject, { type: 'text' }> { return o.type === 'text' }
|
||||
function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> { return o.type === 'button' }
|
||||
function isInput(o: WObject): o is Extract<WObject, { type: 'input' }> { return o.type === 'input' }
|
||||
function isSelect(o: WObject): o is Extract<WObject, { type: 'select' }> { return o.type === 'select' }
|
||||
function isTextarea(o: WObject): o is Extract<WObject, { type: 'textarea' }> { return o.type === 'textarea' }
|
||||
function isRadio(o: WObject): o is Extract<WObject, { type: 'radio' }> { return o.type === 'radio' }
|
||||
function isCheckbox(o: WObject): o is Extract<WObject, { type: 'checkbox' }> { return o.type === 'checkbox' }
|
||||
|
||||
function 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)}`
|
||||
@@ -20,7 +25,82 @@ function renderObject(o: WObject): { html: string; css: string } {
|
||||
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
|
||||
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
|
||||
}
|
||||
// button minimal
|
||||
if (isInput(o)) {
|
||||
const cid = `${o.id}-control`
|
||||
const req = o.props.required ? ' required' : ''
|
||||
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
const described = helpId ? ` aria-describedby="${helpId}"` : ''
|
||||
const pat = o.props.pattern ? ` pattern="${esc(o.props.pattern)}"` : ''
|
||||
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
|
||||
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
|
||||
const html = [
|
||||
`<div id="${o.id}">`,
|
||||
`<label for="${cid}">${esc(o.props.label)}</label>`,
|
||||
`<input id="${cid}" name="${esc(o.props.name)}"${ph}${req}${described}${pat}${min}${max}/>`,
|
||||
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
|
||||
`</div>`,
|
||||
].join('')
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
if (isSelect(o)) {
|
||||
const cid = `${o.id}-control`
|
||||
const opts = (o.props.options || [])
|
||||
.map((opt) => `<option value="${esc(opt)}"${o.props.defaultValue === opt ? ' selected' : ''}>${esc(opt)}</option>`)
|
||||
.join('')
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
const described = helpId ? ` aria-describedby="${helpId}"` : ''
|
||||
const html = [
|
||||
`<div id="${o.id}">`,
|
||||
`<label for="${cid}">${esc(o.props.label)}</label>`,
|
||||
`<select id="${cid}" name="${esc(o.props.name)}"${described}>${opts}</select>`,
|
||||
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
|
||||
`</div>`,
|
||||
].join('')
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
if (isTextarea(o)) {
|
||||
const cid = `${o.id}-control`
|
||||
const req = o.props.required ? ' required' : ''
|
||||
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
|
||||
const rows = Number(o.props.rows ?? 3)
|
||||
const helpId = o.props.help ? `${cid}-help` : ''
|
||||
const described = helpId ? ` aria-describedby="${helpId}"` : ''
|
||||
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
|
||||
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
|
||||
const html = [
|
||||
`<div id="${o.id}">`,
|
||||
`<label for="${cid}">${esc(o.props.label)}</label>`,
|
||||
`<textarea id="${cid}" name="${esc(o.props.name)}" rows="${rows}"${ph}${req}${described}${min}${max}></textarea>`,
|
||||
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
|
||||
`</div>`,
|
||||
].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 `<div><input type="radio" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
|
||||
}).join('')
|
||||
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
|
||||
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].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 `<div><input type="checkbox" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
|
||||
}).join('')
|
||||
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
|
||||
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].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 = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
|
||||
@@ -40,8 +120,20 @@ export function exportFrame(frame: Frame): Exported {
|
||||
}
|
||||
const inner = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
|
||||
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;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#e5e7eb;--disabled-fg:#9ca3af}`,
|
||||
`[data-theme="light"]{--bg:#ffffff;--text:#0f172a;--border:#cbd5e1;--primary:#0ea5e9;--danger:#ef4444;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#e5e7eb;--disabled-fg:#6b7280}`,
|
||||
`[data-theme="dark"]{--bg:#111111;--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#1f2937;--disabled-fg:#9ca3af}`,
|
||||
`.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)}`,
|
||||
`:focus-visible{outline:2px solid var(--ring);outline-offset:var(--ring-offset)}`,
|
||||
`input:focus,select:focus,textarea:focus{outline:2px solid var(--primary);outline-offset:2px}`,
|
||||
`input:invalid,select:invalid,textarea:invalid{border-color:var(--danger)}`,
|
||||
`input:disabled,select:disabled,textarea:disabled{background:var(--disabled-bg);color:var(--disabled-fg)}`,
|
||||
`input[readonly],select[readonly],textarea[readonly]{color:var(--disabled-fg)}`,
|
||||
`fieldset{border:1px solid var(--border)}`,
|
||||
`legend{color:var(--text)}`,
|
||||
...piecesCss,
|
||||
].join('\n')
|
||||
const html = [
|
||||
|
||||
+64
-1
@@ -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<typeof objectSchema>
|
||||
|
||||
export const layerSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
async function getLefts(page: import('@playwright/test').Page) {
|
||||
const els = page.locator('[data-testid^="obj-text-"]')
|
||||
const count = await els.count()
|
||||
const results: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const style = await els.nth(i).evaluate((n) => (n as HTMLElement).style.left || '0')
|
||||
results.push(parseInt(style || '0', 10))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Group drag', () => {
|
||||
test('marquee select two items and drag group by 16px on X (snaps to grid)', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('canvas not found')
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + 240, box.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
const before = await getLefts(page)
|
||||
|
||||
// Drag one of selected (the second) by +16 on X
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + 21, tbox.y + 5) // ~+16 on X
|
||||
await page.mouse.up()
|
||||
|
||||
const after = await getLefts(page)
|
||||
// Expect both moved by same snapped delta
|
||||
const expectedDx = snap8(before[1] + 16) - before[1]
|
||||
expect(after.length).toBeGreaterThanOrEqual(2)
|
||||
expect(after[0]).toBe(before[0] + expectedDx)
|
||||
expect(after[1]).toBe(before[1] + expectedDx)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
async function getBoxStyles(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
const height = await el.evaluate((n) => parseInt((n as HTMLElement).style.height || '0', 10))
|
||||
const transform = await el.evaluate((n) => (n as HTMLElement).style.transform || '')
|
||||
return { left, top, width, height, transform }
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Group resize/rotate and guide live region', () => {
|
||||
test('group E handle resizes both by snapped delta without shifting x', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('canvas not found')
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + 260, box.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
const b0 = await getBoxStyles(page, 0)
|
||||
const b1 = await getBoxStyles(page, 1)
|
||||
|
||||
// Drag east handle of second by +16px
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + tbox.width - 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + tbox.width + 16, tbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBoxStyles(page, 0)
|
||||
const a1 = await getBoxStyles(page, 1)
|
||||
|
||||
const dw = snap8(b1.width + 16) - b1.width
|
||||
expect(a0.left).toBe(b0.left)
|
||||
expect(a1.left).toBe(b1.left)
|
||||
expect(a0.width).toBe(b0.width + dw)
|
||||
expect(a1.width).toBe(b1.width + dw)
|
||||
})
|
||||
|
||||
test('rotate handle snaps to 15deg and live region includes guide coords while dragging', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
|
||||
const target = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
|
||||
// Select
|
||||
await target.click()
|
||||
|
||||
// Drag rotate handle horizontally to cause rotation ~15deg (snapped)
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/가이드\s*x=|y=/)
|
||||
await page.mouse.up()
|
||||
|
||||
const a = await getBoxStyles(page, 0)
|
||||
expect(a.transform).toMatch(/rotate\(15deg\)|rotate\(30deg\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
async function getLefts(page: import('@playwright/test').Page) {
|
||||
const els = page.locator('[data-testid^="obj-text-"]')
|
||||
const count = await els.count()
|
||||
const results: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const style = await els.nth(i).evaluate((n) => (n as HTMLElement).style.left || '0')
|
||||
results.push(parseInt(style || '0', 10))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Group rotate then drag with snap and live region', () => {
|
||||
test('after rotating multiselect, dragging group by 13px snaps to 16px; live region says 선택 2개', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
// Rotate ~15deg via rotate handle
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await expect(live).toContainText(/가이드\s*x=|y=/)
|
||||
await page.mouse.up()
|
||||
|
||||
const before = await getLefts(page)
|
||||
|
||||
// Drag group by ~13px on X; expect grid snap to 16
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + 5 + 13, tbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const after = await getLefts(page)
|
||||
const dx = after[1] - before[1]
|
||||
expect(dx).toBe(16)
|
||||
expect(after[0] - before[0]).toBe(16)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
async function getBox(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
const height = await el.evaluate((n) => parseInt((n as HTMLElement).style.height || '0', 10))
|
||||
return { left, top, width, height }
|
||||
}
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
test.describe('WYSIWYG - Group rotate then resize (N/S) with snap', () => {
|
||||
test('rotate ~15°, then group resize S by +16 (snap) — heights grow equally, top unchanged', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
// rotate to ~15°
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const b0 = await getBox(page, 0)
|
||||
const b1 = await getBox(page, 1)
|
||||
|
||||
// resize south edge of the second by +16px
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + tbox.height - 3)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + tbox.height + 16)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBox(page, 0)
|
||||
const a1 = await getBox(page, 1)
|
||||
|
||||
const dh = snap8(b1.height + 16) - b1.height
|
||||
expect(a0.top).toBe(b0.top)
|
||||
expect(a1.top).toBe(b1.top)
|
||||
expect(a0.height).toBe(b0.height + dh)
|
||||
expect(a1.height).toBe(b1.height + dh)
|
||||
})
|
||||
|
||||
test('rotate ~15°, then group resize N by +16 (snap) — heights grow equally, top shifts equally', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
// rotate to ~15°
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const b0 = await getBox(page, 0)
|
||||
const b1 = await getBox(page, 1)
|
||||
|
||||
// resize north edge of the first by -16px (drag up)
|
||||
const first = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const fbox = await first.boundingBox()
|
||||
if (!fbox) throw new Error('first not found')
|
||||
await page.mouse.move(fbox.x + 5, fbox.y + 3)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(fbox.x + 5, fbox.y - 16)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBox(page, 0)
|
||||
const a1 = await getBox(page, 1)
|
||||
|
||||
const dh = snap8(b0.height + 16) - b0.height
|
||||
const dy = a0.top - b0.top
|
||||
expect(a0.height).toBe(b0.height + dh)
|
||||
expect(a1.height).toBe(b1.height + dh)
|
||||
expect(dy).toBeLessThanOrEqual(-16)
|
||||
expect(a1.top - b1.top).toBe(dy)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
async function getBox(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
const height = await el.evaluate((n) => parseInt((n as HTMLElement).style.height || '0', 10))
|
||||
return { left, top, width, height }
|
||||
}
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
test.describe('WYSIWYG - Group rotate then resize (E/W) with snap', () => {
|
||||
test('rotate ~15°, then group resize E by +16 (snap) — widths grow equally, left unchanged', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
// rotate via handle to ~15°
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const b0 = await getBox(page, 0)
|
||||
const b1 = await getBox(page, 1)
|
||||
|
||||
// resize east handle of the second by +16px (should snap to +16)
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + tbox.width - 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + tbox.width + 16, tbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBox(page, 0)
|
||||
const a1 = await getBox(page, 1)
|
||||
|
||||
const dw = snap8(b1.width + 16) - b1.width
|
||||
expect(a0.left).toBe(b0.left)
|
||||
expect(a1.left).toBe(b1.left)
|
||||
expect(a0.width).toBe(b0.width + dw)
|
||||
expect(a1.width).toBe(b1.width + dw)
|
||||
})
|
||||
|
||||
test('rotate ~15°, then group resize W by +16 (snap) — widths grow equally, left shifts equally', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
// rotate via handle to ~15°
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const b0 = await getBox(page, 0)
|
||||
const b1 = await getBox(page, 1)
|
||||
|
||||
// resize west handle of the first by -16px (drag left)
|
||||
const first = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const fbox = await first.boundingBox()
|
||||
if (!fbox) throw new Error('first not found')
|
||||
await page.mouse.move(fbox.x + 5, fbox.y + 5) // move inside to be safe
|
||||
await page.mouse.move(fbox.x + 2, fbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(fbox.x - 16, fbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBox(page, 0)
|
||||
const a1 = await getBox(page, 1)
|
||||
|
||||
const dw = snap8(b0.width + 16) - b0.width
|
||||
const dx = a0.left - b0.left
|
||||
expect(a0.width).toBe(b0.width + dw)
|
||||
expect(a1.width).toBe(b1.width + dw)
|
||||
expect(dx).toBeLessThanOrEqual(-16)
|
||||
expect(a1.left - b1.left).toBe(dx)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
test.describe('WYSIWYG - Guides toggle and multiselect live region', () => {
|
||||
test('toggles guides and announces multiselect in aria-live', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
|
||||
// Canvas and live region present
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*0개/)
|
||||
|
||||
// Add two text objects
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('canvas not found')
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + 300, box.y + 200)
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
// Guides toggle
|
||||
const guidesBtn = page.getByRole('button', { name: /Guides/i })
|
||||
await expect(guidesBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
await guidesBtn.click()
|
||||
await expect(guidesBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
await guidesBtn.click()
|
||||
await expect(guidesBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
test.describe('WYSIWYG - Guides toggle with group rotate + snap + live region', () => {
|
||||
test('guides ON: after multiselect and rotate, dragging snaps and aria-live shows guides + selection', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Turn on Guides
|
||||
const guidesBtn = page.getByRole('button', { name: /Guides/i })
|
||||
await guidesBtn.click()
|
||||
|
||||
// Marquee select both
|
||||
const cbox = await canvas.boundingBox()
|
||||
if (!cbox) throw new Error('canvas not found')
|
||||
await page.mouse.move(cbox.x + 40, cbox.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cbox.x + 260, cbox.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
// Rotate ~15deg
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
await expect(live).toContainText(/가이드\s*x=|y=/)
|
||||
await page.mouse.up()
|
||||
|
||||
// Drag group by ~13px on X to verify grid snap still applies with guides
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
|
||||
// Read before
|
||||
const leftsBefore = await page.locator('[data-testid^="obj-text-"]').evaluateAll((nodes) => nodes.map(n => parseInt((n as HTMLElement).style.left || '0', 10)))
|
||||
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + 5 + 13, tbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const leftsAfter = await page.locator('[data-testid^="obj-text-"]').evaluateAll((nodes) => nodes.map(n => parseInt((n as HTMLElement).style.left || '0', 10)))
|
||||
const dx = leftsAfter[1] - leftsBefore[1]
|
||||
expect(dx).toBe(16)
|
||||
expect(leftsAfter[0] - leftsBefore[0]).toBe(16)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
async function getBox(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
return { left, top, width }
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Rotate then drag snap priority', () => {
|
||||
test('after 15deg rotation, dragging still prefers edge over grid; live region announces guides', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
const first = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const second = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
|
||||
// Select second
|
||||
await second.click()
|
||||
|
||||
// Rotate ~15deg using rotate handle
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/가이드\s*x=|y=/)
|
||||
await page.mouse.up()
|
||||
|
||||
// Drag rotated second near first's right edge → should still snap by edge priority
|
||||
const b1 = await first.boundingBox()
|
||||
const b2 = await second.boundingBox()
|
||||
if (!b1 || !b2) throw new Error('objects not found')
|
||||
const targetX = b1.x + b1.width - 2
|
||||
await page.mouse.move(b2.x + 5, b2.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(targetX, b2.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const f = await getBox(page, 0)
|
||||
const s = await getBox(page, 1)
|
||||
const expectedLeft = f.left + f.width
|
||||
expect(Math.abs(s.left - expectedLeft)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
// Helper to read left/top/width from inline styles
|
||||
async function getBox(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
return { left, top, width }
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Snap priority (edge > center > grid)', () => {
|
||||
test('dragging near another object prefers edge snap over grid', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Add two text objects
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select the second to get its bbox and then drag it near the first one's right edge
|
||||
const first = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const second = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
|
||||
const b1 = await first.boundingBox()
|
||||
const b2 = await second.boundingBox()
|
||||
if (!b1 || !b2) throw new Error('objects not found')
|
||||
|
||||
// Drag second so that its left aligns to first.right (edge snapping scenario)
|
||||
// move handle point from second.x+5 to target x = first.right - 2 (close enough to trigger snap)
|
||||
const targetX = b1.x + b1.width - 2
|
||||
await page.mouse.move(b2.x + 5, b2.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(targetX, b2.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
// Read inline left/width to verify alignment: second.left ~= first.left + first.width
|
||||
const s = await getBox(page, 1)
|
||||
const f = await getBox(page, 0)
|
||||
const expectedLeft = f.left + f.width
|
||||
// Allow small tolerance (±1) due to rounding during drag
|
||||
expect(Math.abs(s.left - expectedLeft)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('dragging near another object prefers center snap over grid', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
const first = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const second = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const b1 = await first.boundingBox()
|
||||
const b2 = await second.boundingBox()
|
||||
if (!b1 || !b2) throw new Error('objects not found')
|
||||
|
||||
// Target the center alignment: move second so its centerX ~= first.centerX
|
||||
const firstCenterX = b1.x + b1.width / 2
|
||||
await page.mouse.move(b2.x + b2.width / 2, b2.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(firstCenterX, b2.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const f = await getBox(page, 0)
|
||||
const s = await getBox(page, 1)
|
||||
const sCenter = s.left + Math.round((await second.evaluate((n)=>parseInt((n as HTMLElement).style.width||'0',10))) / 2)
|
||||
expect(Math.abs(sCenter - (f.left + f.width / 2))).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('when no nearby objects, grid snapping applies (13px → 16px)', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
|
||||
const obj = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const b = await obj.boundingBox()
|
||||
if (!b) throw new Error('object not found')
|
||||
|
||||
const before = await getBox(page, 0)
|
||||
await page.mouse.move(b.x + 5, b.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + 5 + 13, b.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const after = await getBox(page, 0)
|
||||
expect(after.left - before.left).toBe(16)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user