Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f594a389fd | |||
| fc0134ffb5 | |||
| 8f51494433 | |||
| e602b16254 | |||
| e4287b8567 | |||
| d08f050bfd | |||
| d2575c9b19 | |||
| 4d5477f9ff | |||
| dc54226351 | |||
| 59f84a7149 | |||
| 5bf78b3e01 | |||
| cee57c8fcd | |||
| 1c607f6331 | |||
| 9ced249015 | |||
| 2e7fc18fb4 | |||
| e34a2c9192 | |||
| 928cf744e6 | |||
| 971b609305 | |||
| 2070b52023 | |||
| 713648fb3a | |||
| 498ffb675a | |||
| d4280ef04c | |||
| 302ab79dd4 | |||
| 258cf1dc00 | |||
| 1b1b916a30 | |||
| ccb706de5b | |||
| 66cb36cd9d | |||
| 98d33fc0d0 | |||
| 1737ae172e | |||
| e9655c19cc | |||
| 5dce80e56d | |||
| 029b5a1fac | |||
| e32d59ca44 | |||
| 03d8d84a3c | |||
| 86155c9fd3 | |||
| d202cde689 | |||
| ba2ba588f7 | |||
| 396d0ac7a0 | |||
| 19a39230be | |||
| c519055e90 | |||
| 7928f9b6c5 | |||
| 3c5fdba5c6 | |||
| cf8f02bfca | |||
| f7d161fef6 | |||
| 92aa4f19c7 | |||
| 3708ca3521 | |||
| 9faef5c827 | |||
| 7bf16bfeee | |||
| 0eb056e6b5 | |||
| ed7f6189eb | |||
| 689edeaf24 | |||
| 38066e3030 | |||
| 13439a384f | |||
| 7f6b463520 | |||
| a5b0f07f2a | |||
| 7e7eb1be15 | |||
| 2d6afaf665 | |||
| 31eaa9343a | |||
| d074adda9a | |||
| 032372bb01 | |||
| 294297eef7 | |||
| 176568f976 | |||
| ab2fd6070a | |||
| 3c919adae5 | |||
| 6a9a7939b2 | |||
| 894acbb6a1 | |||
| 899a1ba1a6 | |||
| f385966f87 | |||
| c4d46a5de3 | |||
| 39c64e3acd | |||
| df5786e418 | |||
| 9b8fbd6360 | |||
| 8ada2d1947 | |||
| 1a405a54fa | |||
| 5c23cf3873 | |||
| cc2b3039a3 | |||
| 0561c5d63c | |||
| e6c897a863 | |||
| 39318dd40d | |||
| be80f9339b | |||
| df41aac2b1 |
@@ -43,6 +43,33 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Ensure artifacts export folder
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ./artifacts/export
|
||||
|
||||
- name: Verify bundle integrity (optional)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f ./artifacts/export/assets.integrity.index.json ]; then
|
||||
echo "Found integrity index; generating verify inputs"
|
||||
npm run verify:make-json
|
||||
echo "Running verify CLI"
|
||||
npm run verify:run | tee /tmp/verify_output.json
|
||||
echo "--- verify summary (for quick view) ---"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
try {
|
||||
const txt = fs.readFileSync("/tmp/verify_output.json", "utf-8");
|
||||
const obj = JSON.parse(txt);
|
||||
const s = obj.summary || {};
|
||||
console.log(`ok=${obj.ok} total=${s.total||0} missing=${s.missing||0} integMismatch=${s.integrityMismatches||0} sizeMismatch=${s.sizeMismatches||0} bundleHashMismatch=${s.bundleHashMismatch||false}`);
|
||||
} catch (e) { console.log("(verify summary parse skipped)"); }
|
||||
'
|
||||
else
|
||||
echo "No integrity index at ./artifacts/export/assets.integrity.index.json; skipping integrity verification"
|
||||
fi
|
||||
|
||||
- name: Auto-merge PR on green
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
|
||||
@@ -36,6 +36,8 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
.windsurf
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
+726
@@ -0,0 +1,726 @@
|
||||
# 메인플랜: 랜딩 페이지 빌더
|
||||
|
||||
## [정렬] WYSIWYG 빌더 목표/요구사항/수용기준 (2025-11-17)
|
||||
- **목표**
|
||||
- /[locale]/wysiwyg 에서 비개발자도 랜딩을 시각적으로 제작(Add→편집→Export)할 수 있다.
|
||||
- Export 산출물은 ZIP(index.html/css/js/assets/*, site.webmanifest, robots.txt, 무결성 파일)을 포함한다.
|
||||
|
||||
- **확정 요구사항**
|
||||
- R1: 텍스트/이미지/버튼 추가 및 속성 편집(X/Y/W/H/Rotate + 타입별 속성)
|
||||
- R2: 조작 UX(드래그/리사이즈/회전, 8px/15° 스냅, 멀티선택·마퀴, 키보드 너지, 가이드 표시, a11y 라이브 리전)
|
||||
- R3: 레이어 관리(순서 이동, lock, visible 토글)
|
||||
- R4: Export ZIP 생성(무결성 파일 포함) 및 verify ok
|
||||
- R5: 경로 고정(/[locale]/wysiwyg)
|
||||
|
||||
- **수용 기준(DoD)**
|
||||
- A1: /wysiwyg에서 Add Text → 속성 수정 → Export 실행 시 ZIP 생성, HTML/CSS에 수정 반영
|
||||
- A2: 드래그/리사이즈/회전 스냅 동작 및 가이드 표시 스모크 그린
|
||||
- A3: 라이브 리전 문구가 선택/가이드/너지에 맞춰 갱신
|
||||
- A4: 생성된 ZIP에 대해 verifyIntegrity 결과 ok=true
|
||||
- A5: 레이어 순서 변경이 렌더에 반영
|
||||
|
||||
- **TDD 매핑**
|
||||
- R1 → PropsPanel.test(기존) + WysiwygBuilderPage 추가/수정 반영 스모크(신규)
|
||||
- R2 → Canvas.*.test(기존) + 빌더 멀티선택/마퀴 스모크(신규)
|
||||
- R3 → LayersPanel.test(기존) + 빌더 순서 반영(신규)
|
||||
- R4 → exportFrame/buildZip/무결성(기존) + 빌더 Export 스모크(신규)
|
||||
- R5 → 라우트 스모크(기존)
|
||||
|
||||
- **순차 작업 계획**
|
||||
1) 수용 테스트 추가(빌더 Add→Edit→Export, 레이어 순서 반영, 멀티선택/마퀴 스모크)
|
||||
2) /wysiwyg Export에 무결성 파일 포함 연동
|
||||
3) 최소 UI 토글(가이드/그리드) 노출 및 문서 업데이트
|
||||
4) 전체 그린 확인 후 브랜치 범위 점검 → 푸쉬
|
||||
|
||||
### 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 임계)
|
||||
- 수직 중심 정렬 가이드/스냅
|
||||
- 수평 중심 정렬 가이드/스냅
|
||||
- 좌/우/상/하 가장자리 정렬 가이드/스냅
|
||||
- TDD
|
||||
- `components/wysiwyg/Canvas.guidelines.advanced.test.tsx`
|
||||
- 수직 중심 스냅 가이드(8px) 검증. Moveable 모킹, `act`/`waitFor` 적용
|
||||
- `components/wysiwyg/Canvas.guidelines.horizontal.test.tsx`
|
||||
- 수평 중심 스냅 가이드(8px) 검증
|
||||
- `components/wysiwyg/Canvas.guidelines.edges.test.tsx`
|
||||
- 좌/우 에지 스냅 가이드(8px) 1차 케이스 검증(T→L 등 에지 페어)
|
||||
- 구현
|
||||
- `components/wysiwyg/Canvas.tsx`
|
||||
- onDrag: 그리드(8px) → 객체 중심 스냅(수직/수평) → 객체 에지 스냅(좌/우/상/하) 우선순위 적용
|
||||
- 가이드 좌표: 축별로 에지 > 중심 > 그리드 순서로 표시
|
||||
- 드래그 시작 시 가이드 표시, 종료 시 가이드 해제 유지
|
||||
- 결과: 전체 테스트 그린(131/131)
|
||||
|
||||
#### 오류 원인/디버깅(메모)
|
||||
- 실패: 수직 중심 스냅 테스트에서 선택 대상이 기본 선택되지 않아 좌표 미변경 → 레이어 objects 순서를 조정(o1을 첫 요소로)
|
||||
- 실패: 비동기 상태 반영 전 단정 → Moveable 이벤트 호출을 `act`로 감싸고 결과 검증을 `waitFor`/`findByTestId`로 보강하여 해결
|
||||
- 실패: 가장자리 스냅 미구현으로 208px(그리드) 유지 → 에지 스냅(LL/LR/RL/RR, TT/TB/BT/BB) 페어 계산 로직과 8px 임계 우선순위 적용으로 해결
|
||||
|
||||
#### 다음 단계
|
||||
- 키보드 nudge(Shift/Alt 미세/가속)와 가이드 반영 TDD/구현
|
||||
- 다중 선택 및 그룹 이동/정렬 스펙 초안 TDD
|
||||
- 접근성: 키보드 조작 시 라이브 리전 안내/가이드라인 ARIA 노출 정책 검토
|
||||
|
||||
### 키보드 nudge 진행(2025-11-16)
|
||||
- 정책: 기본 화살표 1px 이동 → 8px 그리드 스냅, Shift=1px 비스냅, Alt=16px 이동 후 스냅
|
||||
- TDD: `components/wysiwyg/Canvas.nudge.test.tsx`에 Shift/Alt 케이스 추가
|
||||
- 구현: `components/wysiwyg/Canvas.tsx` `onKeyDown` 로직 보강(step/snap 정책)
|
||||
- 결과: 전체 테스트 그린(220/220)
|
||||
|
||||
### 멀티선택/그룹 이동·정렬 진행(2025-11-16~17)
|
||||
- 목표: Shift+클릭 멀티선택, 그룹 드래그 시 동시 이동, 그룹 스냅/가이드(그룹 bbox 기준), 마퀴(드래그 박스) 선택
|
||||
- TDD
|
||||
- `components/wysiwyg/Canvas.multiselect.test.tsx`
|
||||
- Shift+클릭으로 다중 선택 토글, 선택 아웃라인 표시, 그룹 드래그 시 동시 이동
|
||||
- `components/wysiwyg/Canvas.multigroup.guides.test.tsx`
|
||||
- 그룹 bbox 기준 중심/에지 스냅, 혼합 케이스(에지>중심), 다수 후보 중 최근접 우선 검증
|
||||
- 그리드 스냅 근사치(±8px)로 인한 픽셀 오차는 상대 delta로 검증(안정성 강화)
|
||||
- `components/wysiwyg/Canvas.marquee.test.tsx`
|
||||
- 빈 캔버스 드래그로 마퀴(드래그 박스) 선택. 교차하는 객체 자동 선택. 빈 클릭 시 선택 해제
|
||||
- 구현: `components/wysiwyg/Canvas.tsx`
|
||||
- 상태: `selectedIds`(배열)로 전환, 마지막 선택을 Moveable `target`으로 사용
|
||||
- Shift+클릭 토글 선택. 선택된 모든 객체에 아웃라인/핸들 표시
|
||||
- 그룹 드래그: 마지막 선택에서 제안된 좌표 → 그룹 bbox로 변환해 스냅 후보(중심/에지) 계산 → 스냅된 그룹 delta를 모든 선택 객체에 적용
|
||||
- 가이드: 그룹 컨텍스트에서 에지>중심>그리드 우선 표시(축별)
|
||||
- 마퀴: 캔버스 `mousedown→mousemove→mouseup`으로 사각형 추적, 교차 판정으로 선택 세트 갱신. 빈 캔버스 클릭 시 선택 해제
|
||||
- 테스트 환경 폴리필: `document.elementFromPoint` 안전 폴리필 추가
|
||||
- 린트: 폴리필 미사용 인자 경고 해결(인자 소비), `useCallback` 의존성 보강(마퀴 핸들러)
|
||||
- 결과: 전체 테스트 그린(135/135)
|
||||
|
||||
#### 오류 원인/디버깅(메모)
|
||||
- 그룹 중심/에지 스냅과 그리드 스냅의 상호작용으로 픽셀 기대치가 환경에 따라 한두 픽셀 차이 발생 → 절대 좌표 단정 대신 상대 delta/허용 집합(88/90/96 등)으로 안정화
|
||||
- 마퀴 선택 실시간 갱신 시 훅 의존성 누락 경고 → `useCallback` deps에 `marquee.active/x0/y0` 및 `model.layers` 반영
|
||||
- jsdom 환경의 `elementFromPoint` 부재로 이벤트 경로 해석 이슈 → 폴리필로 무해화(`() => null`)
|
||||
|
||||
#### 다음 단계
|
||||
- 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검
|
||||
- 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계
|
||||
|
||||
### WYSIWYG 빌더 Export/이미지/Props 동기화 진행(2025-11-17)
|
||||
- 목표: /wysiwyg에서 실제 랜딩에 가까운 HTML/CSS Export와 로컬 이미지 업로드, 속성 패널과 Canvas의 일관된 동기화 확보
|
||||
- TDD
|
||||
- `lib/wysiwyg/export.test.ts`
|
||||
- 버튼 오브젝트를 `<a id="..." href="...">label</a>`로 내보내는지 검증
|
||||
- 숨김 레이어(`visible: false`)가 HTML/CSS에 포함되지 않는지 검증
|
||||
- `exportFrame` 결과 HTML이 `<!doctype html>`, `<link rel="stylesheet" href="styles.css" />`, `<script src="app.js"></script>`를 포함하는 전체 문서 구조인지 검증
|
||||
- `components/wysiwyg/WysiwygBuilder.acceptance.test.tsx`
|
||||
- 로컬 이미지 업로드 시 Canvas에 `<img src="/assets/...">`가 렌더되는지 검증
|
||||
- PropsPanel에서 Width/Height/Rotate 변경이 Canvas 스타일(width/height/transform)에 반영되는지 검증
|
||||
- PropsPanel에서 Text/Label 변경이 Canvas 텍스트/버튼 콘텐츠에 반영되는지 검증
|
||||
- 구현
|
||||
- `lib/wysiwyg/export.ts`
|
||||
- frame 내용을 `<div class="frame-scale"><div class="frame">...</div></div>`로 감싼 뒤, `<!doctype html><html lang="en"><head>...styles.css...</head><body>...app.js...</body></html>` 전체 문서로 래핑
|
||||
- 숨김 레이어는 HTML/CSS 생성 시 스킵, 버튼은 `<a>`로 내보내되 위치/크기/회전은 CSS에서 절대 좌표로 표현
|
||||
- `components/wysiwyg/WysiwygBuilderPage.tsx`
|
||||
- 페이지 수명 주기 동안 유지되는 `AssetManager`를 `useRef`로 보관
|
||||
- 파일 업로드 input(`data-testid="wysiwyg-image-input"`) 추가 후, 업로드된 이미지를 `AssetManager.add`로 수집하고 `props.src="/assets/..."`인 image WObject를 Base 레이어에 추가
|
||||
- Export 시 `assetManager.toZipStructure()`를 `buildZip`의 `assets`로 전달하고, 기존 무결성 파일(`assets.json`, `assets.index.json`, `assets.integrity.index.json`)을 extras에 포함
|
||||
- `components/wysiwyg/PropsPanel.tsx`
|
||||
- 내부 `model`을 `object` prop과 `useEffect`로 동기화하여 선택 변경 시 최신 값 반영
|
||||
- Text용 Text 필드(`aria-label="Text"` → `props.text`), Button용 Label 필드(`aria-label="Label"` → `props.label`) 추가
|
||||
- 결과
|
||||
- /wysiwyg에서 Add Text/Image/Button, PropsPanel 편집, 로컬 이미지 업로드, Export ZIP(index.html/styles.css/app.js/assets/* + 무결성 파일)까지 한 플로우가 acceptance 테스트로 검증되는 상태
|
||||
- `npm test -s` 기준 145 파일 / 246 테스트 전체 그린
|
||||
|
||||
### 접근성 라이브 리전(2025-11-17)
|
||||
- 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내
|
||||
- TDD
|
||||
- `components/wysiwyg/Canvas.a11y.guides.test.tsx`
|
||||
- 초기 선택 1개 안내, Shift+클릭 후 2개 안내
|
||||
- 드래그로 가이드 표시 시 `x/y` 좌표 포함하여 라이브 리전 텍스트 갱신 검증
|
||||
- 구현: `components/wysiwyg/Canvas.tsx`
|
||||
- `role="status" aria-live="polite"` 라이브 리전 요소 추가(`data-testid="aria-live"`)
|
||||
- 텍스트는 `useMemo`로 파생 계산(선택 수, 가이드 표시 여부, 가이드 좌표)
|
||||
- 상태(setState) 기반 이펙트 대신 파생 텍스트로 전환하여 렌더 루프/린트 경고 회피
|
||||
- 결과: 테스트 그린(전체 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 테스트)
|
||||
|
||||
## 목표
|
||||
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
||||
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
||||
- 폼: 다양한 필드 타입(파일 업로드 제외), 커스텀 name, action URL 설정. action 미지정 시 Google Sheets 저장.
|
||||
- 다국어 지원(i18n), 접근성 준수, SEO/OG 메타, 분석 스니펫 삽입.
|
||||
|
||||
## 확정 요구사항
|
||||
- 단일 페이지 기준.
|
||||
- 이미지: 외부 URL 또는 로컬 업로드 허용(서버 저장 없음, ZIP 패키징). CDN 허용(예: Google Fonts 등).
|
||||
- 폼 필드 타입: 텍스트/이메일/전화/셀렉트/라디오/체크박스/텍스트에어리어/날짜 등(파일 업로드는 추후).
|
||||
- 검증 규칙 필요. 스팸 방지: 기본(honeypot, 제출지연 타임스탬프).
|
||||
- 제출 경로: 1) 사용자가 action URL 지정 → 2) 미지정 시 Google Sheets.
|
||||
- 기본: Google Apps Script 웹앱 템플릿 가이드 제공(사용자 배포 URL 사용)。
|
||||
- 대안: 서비스계정 + Google Sheets API(선택).
|
||||
- 다국어 지원 필요. 접근성 준수. SEO/OG 포함. 분석 스니펫 삽입 기능 필요.
|
||||
- 스택: React 기반. Next.js(App Router) + TS + Tailwind + shadcn/ui 권장.
|
||||
|
||||
## 아키텍처 개요
|
||||
- 빌더 앱: Next.js(App Router, TS), Tailwind, shadcn/ui, dnd-kit, Zustand.
|
||||
- 도메인 스키마: Page → Section[] → Block[] → Form(fields, validation, actionUrl) → Theme.
|
||||
- 에셋 매니저: 사용자가 업로드한 이미지를 메모리/브라우저에서 관리, 파일명 해싱 후 `assets/` 경로로 참조 재작성.
|
||||
- HTML Exporter: JSON 스키마 → 정적 HTML/CSS/JS 생성. 로컬 업로드 이미지는 `assets/`로 참조, 외부 URL은 그대로 유지.
|
||||
- 제출 경로: action 사용 또는 Google Sheets(기본 Apps Script 템플릿 제공).
|
||||
- i18n: next-intl. a11y: Testing Library + axe-core 스모크. SEO/OG 메타 생성.
|
||||
- 내보내기: ZIP(브라우저 JSZip 우선). 필요 시 서버(API Route) 대안 제공. 로컬 업로드 이미지는 ZIP 내 `assets/` 폴더에 파일로 포함.
|
||||
|
||||
## UI/UX 리디자인 계획(합의안)
|
||||
- **정보 구조(3패널)**
|
||||
- 좌: 컴포넌트 패널(Hero/FAQ/CTA + Form 프리셋)
|
||||
- 중: 캔버스(dnd-kit, 섹션/필드 배치·정렬·삭제)
|
||||
- 우: 인스펙터(선택 요소 속성/검증/스타일 편집)
|
||||
- **상단 툴바**: 언어, 테마, 프리셋, Export ZIP
|
||||
- **프리뷰**: 실시간 렌더, 반응형 뷰포트 토글
|
||||
- **계약 고정**: 스키마(섹션/폼/검증), 상태 인터페이스, Exporter 입·출력
|
||||
|
||||
### 단계별 TDD 진행
|
||||
1) 레이아웃 스캐폴딩(shadcn/ui) + 접근성 라벨링 테스트
|
||||
2) dnd 캔버스 1차: 섹션 드래그 정렬/삭제 테스트 → 최소 구현
|
||||
3) 인스펙터 1차: 선택 섹션/필드 기본 속성 편집 테스트 → 연동
|
||||
4) 컴포넌트 패널: 카드 추가(섹션/필드 프리셋) 테스트 → 드래그 추가
|
||||
5) 프리뷰 강화: 실시간 반영/뷰포트 토글 스모크 테스트
|
||||
|
||||
### 현재 상태(MVP)
|
||||
- 섹션 추가/삭제, 폼 필드 구성(옵션/필수/패턴/최대길이), i18n, ZIP Export, 로컬 에셋 포함 동작 확인
|
||||
- Next.js 16: `middleware` → `proxy` 마이그레이션 완료
|
||||
- dnd 상호작용 테스트 및 구현 안정화(정렬/삭제 버튼 폴백 포함)
|
||||
- 섹션 프리셋 확장: Features/Testimonials 카드 추가 및 프리뷰/Exporter 스텁 반영 완료
|
||||
- SEO/Analytics/OG 인스펙터 추가 및 프리뷰 반영
|
||||
- Exporter: og:image 메타 태그 반영 및 테스트 추가
|
||||
- a11y: jest-axe 스모크 테스트 통과(FormBuilderPanel), dnd-kit 라이브 리전/안내 존재 검증
|
||||
- 라벨-id 매핑 강화(중앙 입력/인스펙터/FAQ/CTA/Features/Testimonials)
|
||||
- 프리뷰 뷰포트 토글(Desktop/Mobile) + localStorage 영속화
|
||||
- Features/Testimonials 인스펙터 편집 UI(리스트 추가/삭제/편집) + 테스트
|
||||
- Exporter 타입 보강(any 제거) 및 CSS 보강(Features/Testimonials)
|
||||
- E2E(Playwright):
|
||||
- 기본 플로우(섹션 추가→편집→프리뷰→Export 클릭)
|
||||
- dnd 키보드 폴백(Up/Down) 재정렬
|
||||
- SEO/Analytics/OG 프리뷰 반영 검증
|
||||
- Export ZIP 메타(SEO/OG/Analytics) 검증(테스트 훅 사용)
|
||||
- 프리뷰 뷰포트 토글(aria-pressed/키보드 접근) 검증
|
||||
|
||||
### a11y/반응형 정책(Exporter)
|
||||
- 문서 내비게이션: skip link, `<main id="main" role="main">` 적용
|
||||
- 섹션 식별: `aria-labelledby` + 섹션 헤딩 id(features/testimonials/faq)
|
||||
- 이미지 대체텍스트: hero img `alt` = heading 파생
|
||||
- 포커스 표시: 포커스 가능한 요소 outline 표시
|
||||
- 반응형: 모바일 컨테이너 패딩 축소, grid 자동 칼럼, `prefers-reduced-motion` 준수
|
||||
|
||||
### 최근 보강 내역(접근성/테스트)
|
||||
- 인스펙터 입력에 `aria-describedby` 연결(SEO/Analytics/OG/Hero/CTA)
|
||||
- SEO/Analytics/OG/Hero/CTA/FAQ 헬프텍스트 추가
|
||||
- Exporter 구조 스냅샷 강화: `<main>` 내부 섹션/폼 포함 검증
|
||||
- Exporter CSS 토큰 스냅샷: skip-link/focus outline/반응형/모션 감소 토큰 존재 검증
|
||||
- Features/Testimonials `aria-labelledby`/헤딩 id 일치 검증 추가
|
||||
- Exporter 헤딩 레벨 일관성 전수 테스트(hero=h1, others=h2, FAQ=h3) 추가
|
||||
- Exporter 색상/타이포그래피 토큰 스모크 테스트 추가
|
||||
- Exporter(Form): helptext ↔ aria-describedby 연결 TDD 완료
|
||||
- 각 필드 `id=field-<name>`와 `<label for>` 연결
|
||||
- `help`가 있을 경우 `id=field-<name>-help` 요소 생성 및 컨트롤에 `aria-describedby` 부여
|
||||
- 라디오 그룹은 `fieldset`에 `aria-describedby` 적용, 옵션별 `id/htmlFor` 연결
|
||||
- next-intl 기본 timeZone 설정: `Asia/Seoul`로 ENVIRONMENT_FALLBACK 경고 제거
|
||||
- a11y 색/폰트 최소 기준 스모크(본문 컬러/폰트 패밀리/모바일 h1 폰트 크기) 추가
|
||||
- CTA 섹션에 `aria-labelledby` + `<h2 id="cta-heading">` 추가(헤딩 레벨 일관성)
|
||||
- 전체 테스트 그린(유닛/컴포넌트/E2E)
|
||||
- Inspector a11y 스모크: SEO/Analytics/OG 입력과 헬프텍스트의 `aria-describedby` 연결 검증
|
||||
- Exporter CSS 스모크: 본문 텍스트 컬러/포커스 outline/모바일 h1 폰트 크기/MQ 토큰 존재 검증
|
||||
- FAQ 인스펙터 Q/A 입력 `aria-describedby`(ins-faq-help) 연결 보강
|
||||
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 추가( Space/Enter ) 검증 추가
|
||||
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 삭제(Remove) Enter/Space 검증 추가
|
||||
- 인스펙터 Remove 버튼 키보드 처리(onKeyDown/onKeyUp) 반영
|
||||
- Exporter 회귀 스냅샷: heading/ARIA/반응형 토큰 통합 검증 추가
|
||||
- Preview 토글 ARIA 회귀 스냅샷(Desktop/Mobile: aria-pressed/controls/describedby)
|
||||
- Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion)
|
||||
- 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재)
|
||||
- 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가
|
||||
- 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링
|
||||
- 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증)
|
||||
- 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `<img>` 렌더 반영)
|
||||
- 브랜치: `feat/image-optimization` (로컬 커밋, 푸쉬 보류)
|
||||
- 이미지 최적화 옵션 2차(TDD): `srcset`만 제공 시 기본 `sizes` 자동 적용(`imageAutoSizes`)
|
||||
- 기본값: `(max-width: 640px) 100vw, 600px`
|
||||
- 테스트: `image.responsive.options.test.ts` (auto on/off 케이스)
|
||||
- 구현: `ExportOptions.imageAutoSizes` 추가 및 `renderHero` 적용
|
||||
- 신규 섹션 추가: Benefits
|
||||
- 스키마 확장(`benefits`): props.items:string[]
|
||||
- Exporter 렌더 추가: `<section class="benefits" aria-labelledby>`, `<h2 id="benefits-heading">`, 리스트 렌더
|
||||
- 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증)
|
||||
- 섹션 회귀 스냅샷(benefits 포함) 보강: sections.regression.benefits.test.ts 추가(ARIA/헤딩/리스트 구조 전수 검증)
|
||||
- 섹션 회귀 스냅샷(CTA/Testimonials): sections.regression.cta-testimonials.test.ts 추가(figure/blockquote/figcaption, CTA 링크 클래스/URL 검증)
|
||||
- 섹션 회귀 스냅샷(Features/FAQ): sections.regression.features-faq.test.ts 추가(리스트/클래스/아이템 수 검증)
|
||||
- Exporter 이스케이프 회귀: sections.escape.regression.test.ts 추가(특수문자 <, >, &, ") 안전 이스케이프 검증)
|
||||
- 인스펙터 a11y 자동 체크 헬퍼 도입: components/testUtils/inspectorA11y.ts + 템플릿 테스트 2종(InspectorA11y.template/sections)
|
||||
- i18n 회귀 확장: Sidebar Add 버튼군(en/ko) 라벨 스모크(i18n.sidebar.actions.test.tsx)
|
||||
- E2E 스모크: 프리뷰/Export 마커(heading/ARIA/반응형 CSS) 존재 검증 추가
|
||||
- SEO description 기본값 설정으로 Export 스키마 안정화
|
||||
- 프리뷰 토글 버튼에 `aria-controls="preview-container"` 추가로 제어 영역 명시
|
||||
- 프리뷰 토글에 sr-only 설명 추가 및 두 토글 버튼 `aria-describedby` 연결
|
||||
- SectionCanvas 제어 버튼 `aria-label` 스모크(Select/Up/Down/Remove) 추가
|
||||
- CSS focus-visible 토큰 스모크(3px solid + HEX 컬러) 추가
|
||||
- 인스펙터 Add 버튼 title 보조 힌트 추가(Features/Testimonials/FAQ) 및 일관성 테스트
|
||||
- SectionCanvas 버튼 title/aria-label 일관성 스모크
|
||||
- 전역 버튼 네이밍 일관성 보강: Inspector Add/Remove 및 SectionCanvas Select/Up/Down/Remove의 title/aria-label 일치 확인 및 스모크 완료
|
||||
- a11y 커버리지 최종 점검: 전 섹션 인스펙터 입력 aria-describedby 연결 확인(체크리스트 충족)
|
||||
|
||||
### 다음 작업(계획)
|
||||
- Exporter 헤딩 레벨 일관성 테스트: hero는 h1, 나머지 섹션은 h2, FAQ 항목은 h3 검증
|
||||
- a11y 색/폰트 최소 기준 스모크: 대비/폰트크기 관련 CSS 토큰/규칙 존재 여부 스냅샷
|
||||
|
||||
### Inspector a11y 커버리지 체크리스트
|
||||
- [x] Hero: `Hero Heading` 입력 `aria-describedby` → `ins-hero-heading-help`
|
||||
- [x] CTA: `CTA Text`/`CTA Href` 입력 `aria-describedby` → `ins-cta-text-help`/`ins-cta-href-help`
|
||||
- [x] FAQ: `FAQ Q n`/`FAQ A n` 입력 `aria-describedby` → `ins-faq-help`
|
||||
- [x] Features: `Feature n` 입력 `aria-describedby` → `ins-features-help`
|
||||
- [x] Testimonials: `Author n`/`Quote n` 입력 `aria-describedby` → `ins-testimonials-help`
|
||||
- [x] Preview 토글: sr-only 설명(`preview-toggle-help`)과 버튼 `aria-describedby` 연결, `aria-controls="preview-container"`
|
||||
|
||||
### CI/테스트 실행
|
||||
- 유닛/컴포넌트: `npm test` (Vitest)
|
||||
- E2E: `npm run test:e2e` (Playwright)
|
||||
- 로컬 개발 서버는 Playwright 설정에서 자동 기동(webServer: `npm run dev`)
|
||||
- GitHub Actions 워크플로: `.github/workflows/ci.yml`
|
||||
- Node 20 + `npm ci`
|
||||
- Vitest 실행 → Playwright 브라우저 설치(`npx playwright install --with-deps`) → E2E 실행
|
||||
|
||||
### CI 최종 점검
|
||||
- main/master 브랜치 푸시/PR 시 워크플로 자동 실행 확인
|
||||
- 새로 추가된 테스트(헤딩 레벨 전수, 색/폰트 스모크) CI 통합 확인
|
||||
- 작업 브랜치 푸시 완료: `chore/ci-verify-a11y-headings` (원격: origin)
|
||||
- PR 생성 및 Actions 결과 기록 예정(base: main)
|
||||
- CI verification note: 러너 온라인 이후, 사소 변경으로 CI 트리거 검증 예정
|
||||
- CI rerun: 워크플로 재실행용 ping 커밋
|
||||
|
||||
### PR/CI 결과(2025-11-14)
|
||||
- PR: feat/ci-verify-run → main 머지 완료
|
||||
- CI: Vitest + Playwright 전부 그린
|
||||
- E2E 추가: Exported page skip-link focus 표시 회귀 테스트 포함
|
||||
- 워크플로: self-hosted runner [macos, host], Playwright 보고서 업로드 아티팩트 활성화
|
||||
- 제안: 다음 릴리스 태그(v0.2.0)로 회귀 커버리지/Benefits 섹션 추가 반영
|
||||
|
||||
#### 결과: CI 통과 (self-hosted Gitea Actions)
|
||||
- PR: https://gitea.jaybe.dev/jaybe/landing-builder/pulls/2
|
||||
- 러너: self-hosted, labels: [macos, host]
|
||||
- 테스트: Unit/Component + E2E 총 7/7 green
|
||||
- 수정 요약(E2E 포커스):
|
||||
- 초기 포커스 선점 방지: `body` 클릭으로 포커스 부여
|
||||
- 스킵링크 등 선행 포커서블 회피: Desktop 버튼에 명시적 `focus()`
|
||||
- 키보드 시나리오: Tab → Mobile 포커스 확인 → Space로 토글 → Shift+Tab → Enter
|
||||
|
||||
##### 추가 CI 결과(2025-11-14)
|
||||
- E2E a11y 스모크 추가: 포커스 아웃라인(3px solid, 브랜드 컬러), 스킵링크 탭 순서(main 포커스 이동)
|
||||
- Exporter JS에 스킵링크 클릭 시 `#main`에 `tabindex="-1"` 부여 후 `focus()` 처리 → CI 그린 통과
|
||||
|
||||
#### 머지 상태
|
||||
- 머지 방식: Squash and merge
|
||||
- 브랜치 정리: `feat/ci-verify-run` 삭제 완료
|
||||
|
||||
### 다음 스프린트 후보 과제
|
||||
- Exporter 회귀 스냅샷 보강(폼 토큰/검증 토큰 범위 확대)
|
||||
- 인스펙터 신규 섹션 추가 시 a11y 베이스라인 재사용 가이드 마련
|
||||
- 버튼 네이밍 일관성 전수 스캔 스모크 범위 확대
|
||||
- node 20 + npm ci
|
||||
- Vitest 실행 → Playwright 브라우저 설치 → E2E 실행
|
||||
|
||||
## 마일스톤
|
||||
1. 스캐폴딩 및 테스트 환경 구성
|
||||
2. 스키마 정의(Zod) 및 HTML Exporter TDD
|
||||
3. 컴포넌트/상태관리(Zustand) 및 인스펙터 기본 기능
|
||||
4. 접근성/키보드 네비게이션 강화 및 테스트 안정화
|
||||
5. CI/CD(Self-hosted Gitea) 연동 및 그린 검증
|
||||
|
||||
## 릴리스
|
||||
### v0.1.0
|
||||
- CI 검증 완료(Self-hosted Gitea runner, labels: [macos, host])
|
||||
- E2E 안정화: 초기 포커스 이슈 제거(Desktop 명시 focus), 스킵링크 고려
|
||||
- Exporter(Form) a11y: helptext ↔ aria-describedby, id/htmlFor 연결, 라디오 fieldset 설명 연결
|
||||
- i18n: en/ko 중첩 구조, 누락 키 보강(builder.action.*, builder.form.*)
|
||||
- next-intl: 기본 timeZone=Asia/Seoul 설정으로 ENVIRONMENT_FALLBACK 경고 제거
|
||||
- 태그: v0.1.0
|
||||
|
||||
## 접근성 베이스라인 가이드(신규 섹션/인스펙터 적용 규칙)
|
||||
- **레이블 연결**: 모든 입력은 `id=ins-...` 또는 `id=field-...` 부여, `<label for>`로 연결
|
||||
- **헬프텍스트 연결**: 헬프 요소 `id=...-help` 생성, 입력에 `aria-describedby`로 연결
|
||||
- **라디오 그룹**: 개별 `<input>` 대신 `<fieldset>`에 `aria-describedby` 적용, 옵션은 `id/htmlFor`로 연결
|
||||
- **버튼 네이밍**: 보이는 텍스트 또는 `aria-label`로 안정적 접근 가능한 이름 제공
|
||||
- **키보드 활성화**: Enter/Space 활성(onClick와 동일 동작), `focus-visible` 스타일 유지
|
||||
- **스킵링크**: `<a class="skip-link">` 제공, 포커스 시 화면 내 표시
|
||||
- **헤딩 레벨**: hero=h1, 1뎁스 섹션=h2, FAQ 항목=h3 일관성 유지
|
||||
- **미리보기 토글**: 토글 버튼에 `aria-pressed`, `aria-controls`, `aria-describedby` 연결
|
||||
- **i18n 키**: 중첩 객체 구조, 신규 키 추가 시 en/ko 동시 보강
|
||||
- **테스트 원칙**: TDD로 유닛/컴포넌트/E2E 및 스냅샷 추가. 포커스/ARIA/토큰 스모크 포함
|
||||
|
||||
### 적용 가이드(신규 섹션 추가 시)
|
||||
- **1) 섹션 구현**: `<section class="<name>" aria-labelledby="<name>-heading">`와 `<h2 id="<name>-heading">` 구성
|
||||
- **2) 인스펙터 입력**: `id=ins-...`, `<label for>`, 헬프텍스트 `...-help` + `aria-describedby` 연결
|
||||
- **3) Exporter 반영**: 섹션 렌더 함수에 위 구조/속성 부여
|
||||
- **4) 테스트 추가**:
|
||||
- 공통 템플릿 사용: `lib/exporter/testUtils/sectionA11y.ts` → `assertSectionA11y(html)`
|
||||
- 샘플: `lib/exporter/sectionA11y.template.test.ts` 참고하여 새 섹션 추가해 검증
|
||||
- 필요 시 개별 스냅샷/스모크: 헤딩/ARIA/키보드/토큰 추가 검증
|
||||
|
||||
## 다음 단계(E2E 초안)
|
||||
- 시나리오: 섹션 추가(Hero/FAQ/Features/Testimonials/CTA) → 인스펙터 편집 → Export ZIP 생성 → HTML 메타/섹션/폼 렌더 확인
|
||||
- 환경: Playwright
|
||||
- 체크리스트:
|
||||
- 섹션 추가/정렬/삭제 동작
|
||||
- 인스펙터 편집 반영(미리보기/Export HTML)
|
||||
- i18n 라벨 안정성(data-testid 사용)
|
||||
- a11y 스모크 무결성 유지
|
||||
|
||||
## TDD 전략
|
||||
- 각 기능 단위로: 실패 테스트 작성 → 최소 구현 → 리팩터 → 커버리지 확인.
|
||||
- 단위: Vitest + Testing Library + jsdom. E2E: Playwright.
|
||||
- 접근성: axe-core 스모크 테스트.
|
||||
|
||||
## 작업 보드(요약)
|
||||
- 스키마/도메인(Zod)
|
||||
- HTML Exporter
|
||||
- 에셋 매니저
|
||||
- 폼 엔진
|
||||
- Sheets 연동
|
||||
- 빌더 UI
|
||||
- 내보내기 ZIP
|
||||
- i18n
|
||||
- a11y/SEO/OG/Analytics
|
||||
|
||||
## 오류/버그 기록
|
||||
- [2025-11-16 22:32] jsdom 환경에서 Moveable 내부 `document.elementFromPoint` 미구현으로 예외 발생 → `Canvas.tsx`에 테스트 전용 폴리필 추가(`elementFromPoint: () => null`)로 해결. 린트 any 제거 위해 타입(`DocWithEFP`) 선언 및 미사용 인자 경고 최소화.
|
||||
- [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인
|
||||
- [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지
|
||||
- [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린
|
||||
- [2025-11-14 20:30] E2E 실패: 스킵링크 활성화 후 main 포커스 미이동 → Exporter JS에 스킵링크 클릭 핸들러 추가(`tabindex=-1` + `focus()`) → 로컬/CI 모두 그린
|
||||
|
||||
- [2025-11-14 23:45] Gitea 라벨 중복 생성/페이로드 오류 → 워크플로 수정
|
||||
- auto-label.yml/auto-pr.yml에서 라벨 생성 로직 제거(기존 레이블만 사용)
|
||||
- 라벨 지정 시 JSON 페이로드를 `{"labels":[id]}` 형태로 고정
|
||||
- 다중 동일 라벨 존재 시 가장 작은 ID를 선택하도록 정렬 적용
|
||||
- ci.yml 자동 머지 가드: PR 라벨 조회 후 `automerge` 존재시에만 머지
|
||||
|
||||
## CI 파이프라인 자동화 결과(automerge)
|
||||
- 브랜치 푸시 → auto-pr로 PR 생성 → auto-label로 `automerge` 부여(기존 레이블만) → ci.yml에서 테스트 그린 시 자동 머지
|
||||
- 안전장치: `automerge` 라벨 없으면 머지 스킵, PR 닫힘/드래프트 시 머지 스킵
|
||||
- 진단 로그: API status/라벨 JSON 일부/머지 응답 코드 출력으로 디버그 용이성 확보
|
||||
|
||||
## 충돌 해결 기록
|
||||
- [2025-11-14 23:50] `.gitea/workflows/auto-pr.yml` main 병합 충돌 해결
|
||||
- 충돌 마커 제거, 메시지 통일: "기존 'automerge' 라벨만, 생성 금지"
|
||||
- PR 번호 해석 재시도 루프 유지 및 라벨 ID 해석 로직 안정화
|
||||
|
||||
## ZIP 검증 TDD(자산/메타)
|
||||
- 통합/메타 테스트 추가: `zip.validation.integrated.test.ts`, `zip.validation.meta.test.ts`
|
||||
- exportPage + buildZip 통합: 자산 경로 재작성 및 포함, 하위 폴더 포함 검증
|
||||
|
||||
### ZIP 검증 TDD Phase4(루트 파일/manifest)
|
||||
- 테스트: `zip.validation.phase4.test.ts`
|
||||
- ZIP 루트에 `favicon.ico` / `site.webmanifest` / `robots.txt` 포함 검증
|
||||
- `site.webmanifest` JSON 파싱 유효성 검증(잘못된 JSON 시 에러)
|
||||
- 구현: `lib/exporter/zip.ts`
|
||||
- `BuildZipInput.extras` 추가(루트 파일 수집)
|
||||
- manifest JSON 파싱 검증 로직 추가
|
||||
- 브랜치/PR: `feat/manifest-robots-auto` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
|
||||
|
||||
### SEO/Perf 개선(TDD): theme-color, hero width/height
|
||||
- 테스트: `lib/exporter/seoPerf.themeColor.heroDims.test.ts`
|
||||
- `<meta name="theme-color" content="#0ea5e9" />` 헤드 포함 검증(page.theme.primaryColor 사용)
|
||||
- hero 이미지에 `width`/`height` 속성 존재 검증(CLS 저감)
|
||||
- 구현: `lib/exporter/html.ts`
|
||||
- `buildHead`에 theme-color 메타 추가
|
||||
- `renderHero` 이미지 태그에 width/height 기본값 추가(1200x675)
|
||||
- 브랜치/PR: `feat/seo-perf-themecolor-hero-dims` → CI 트리거 및 자동 머지 대상
|
||||
|
||||
### Export 옵션 기반(TDD): extras 오버라이드(robots/manifest)
|
||||
- 테스트: `lib/exporter/extras.overrides.test.ts`
|
||||
- robots.txt 내용 오버라이드(Disallow 경로) 반영 검증
|
||||
- manifest 필드(name/short_name/start_url/display/theme_color) 오버라이드 검증
|
||||
- 구현: `lib/exporter/extras.ts`
|
||||
- `buildExtrasForPage(page, overrides?)` 시그니처로 확장
|
||||
- `overrides.robotsText`/`overrides.manifest` 병합 적용, 기본값 유지
|
||||
- 브랜치/PR: `feat/export-options-overrides` → CI 트리거 및 자동 머지 대상
|
||||
### ZIP 검증 TDD Phase5(extras 내용 검증)
|
||||
- 테스트: `lib/exporter/zip.validation.phase5.test.ts`
|
||||
- `robots.txt` 텍스트 베이스라인 확인(`User-agent: *`)
|
||||
- `site.webmanifest` JSON 파싱 및 필수 키(`start_url`, `display`) 확인
|
||||
- 구현: 기존 `buildExtrasForPage`/`buildZip` 경로 사용(추가 구현 불필요)
|
||||
- 브랜치/PR: `feat/zip-validation-phase5` → CI 트리거 및 자동 머지 대상
|
||||
|
||||
### Export 옵션 UI 폴리시(TDD): display 드롭다운 + theme_color HEX 검증
|
||||
- 테스트: `components/ExportOptions.polish.ui.test.tsx`
|
||||
- Manifest `display` 입력이 드롭다운(select)으로 제공되고 옵션 값(`standalone`/`minimal-ui`/`browser`) 중에서만 선택됨을 검증
|
||||
- `theme_color`는 `#RGB`/`#RRGGBB` 형식의 HEX만 허용하며, 잘못된 값은 오버라이드에 반영되지 않음을 검증
|
||||
- 구현:
|
||||
- `components/BuilderClientPage.tsx`
|
||||
- `display` 입력을 `<select>`로 변경하고 옵션 추가
|
||||
- `theme_color`는 정규식 `^#([0-9a-fA-F]{3}){1,2}$` 통과 시에만 `manifestOverrideBase.theme_color`에 반영
|
||||
- 결과: 전체 테스트 그린(유닛/컴포넌트/E2E)
|
||||
- 브랜치/PR 제안: `feat/export-ui-polish` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
|
||||
|
||||
### 폰트 최적화(TDD)
|
||||
- 테스트: `lib/exporter/font.optimization.test.ts`
|
||||
- head에 `<link rel="preconnect" href="https://fonts.googleapis.com">`, `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>` 존재 검증
|
||||
- CSS에 `system-ui` 폴백 포함 검증
|
||||
- 구현: `lib/exporter/html.ts`
|
||||
- head에 googleapis/gstatic preconnect 추가
|
||||
- CSS body `font-family`에 system-ui 폴백 유지
|
||||
- 브랜치/PR: `feat/font-optimization` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
|
||||
|
||||
### 폼 제출: Google Sheets 템플릿 Export 포함(TDD)
|
||||
- 테스트: `lib/exporter/sheets.template.test.ts`
|
||||
- actionUrl 미지정 시 ZIP extras에 `sheets/Code.gs`, `sheets/README.txt` 포함 검증
|
||||
- actionUrl 지정 시 extras에 포함되지 않음 검증
|
||||
- 구현: `lib/exporter/extras.ts`
|
||||
- `buildExtrasForPage(page)`로 extras 생성
|
||||
- Apps Script Code.gs/README.txt 기본 템플릿 제공
|
||||
- 브랜치/PR: `feat/form-sheets-template` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
|
||||
|
||||
### site.webmanifest/robots.txt 자동 생성(TDD)
|
||||
- 테스트: `lib/exporter/manifest.robots.auto.test.ts`
|
||||
- extras에 `site.webmanifest`/`robots.txt` 자동 포함 검증
|
||||
- manifest 파생 키 검사(name/short_name/start_url/display/theme_color)
|
||||
- 구현: `lib/exporter/extras.ts`
|
||||
- `buildExtrasForPage(page)`에서 robots.txt/manifest 자동 생성 및 포함
|
||||
- 브랜치/PR: `feat/manifest-robots-auto` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
|
||||
|
||||
## 의사결정 기록(ADR)
|
||||
- [YYYY-MM-DD] Next.js(App Router) 채택: 생태계/배포/서버리스/SEO 편의성.
|
||||
- [YYYY-MM-DD] Sheets 기본 방안: Apps Script 웹앱 템플릿(사용자 편의/트래픽 분산).
|
||||
- [YYYY-MM-DD] 로컬 이미지 업로드 허용: 서버 저장 없이 ZIP에 `assets/`로 패키징(브라우저 JSZip 우선).
|
||||
|
||||
- [ci] automerge 재검증용 ping (2025-11-14 21:36:38)
|
||||
|
||||
- [ci] auto-pr 라벨 자동부여 재검증 ping (2025-11-14 21:45:14)
|
||||
|
||||
- [ci] auto-label 재트리거 ping (2025-11-14 21:56:22)
|
||||
|
||||
- [ci] automerge 라벨 수동부여 후 재트리거 ping (2025-11-14 22:01:41)
|
||||
|
||||
- [ci] full auto pipeline 재검증 ping (2025-11-14 22:09:02)
|
||||
|
||||
- [ci] auto-label payload fix 재검증 ping (2025-11-14 22:15:14)
|
||||
|
||||
- [ci] auto pipeline 최종 재검증 최종 ping (2025-11-14 22:26:59)
|
||||
|
||||
|
||||
### Export ZIP 무결성 강화(Option2) 진행 현황
|
||||
|
||||
### 목표
|
||||
- Export ZIP 무결성 강화: 자산 무결성 지표와 번들 검증 체계 구축
|
||||
- CI/CD(Gitea)에서 PR 자동화, 라벨링, 조건부 머지 적용
|
||||
- 모든 개발은 TDD로 진행하고, 기능 단위 커밋/대브랜치 단위 푸쉬
|
||||
|
||||
### 브랜치 전략
|
||||
- 대범위 기능 묶음 브랜치: `feat/export-bundle-integrity`
|
||||
- 관련 기능 완료까지 로컬 커밋만 진행, 완료 시점에 일괄 푸쉬
|
||||
|
||||
### 진행 현황
|
||||
- SHA-256(base64) 무결성 구현 및 `assets.integrity.index.json` 생성
|
||||
- `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장
|
||||
- `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트
|
||||
- `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과
|
||||
- `assets.integrity.index.json`에 `size`(byte) 추가, `verifyIntegrity`에서 크기 불일치 검증
|
||||
- `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가
|
||||
- Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화
|
||||
- 브랜치 푸쉬 완료: `feat/export-bundle-integrity`
|
||||
- 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린
|
||||
- 이미지 최적화 옵션(2차): `imageAutoSizes`(srcset만 있을 때 기본 sizes 자동 적용)
|
||||
- 이미지 최적화 옵션(3차): `imageSizesPreset`(mobile-first/desktop-fixed) + 우선순위(imageSizes > preset > auto > none)
|
||||
- 브랜치/PR: `feat/image-optimization` 푸쉬 완료
|
||||
- verifyIntegrity CLI 래퍼(TDD) 추가: `lib/exporter/verify.cli.ts` + 테스트
|
||||
- 폴더 기반 직렬화 유틸(TDD) 추가: `lib/exporter/verify.serialize.ts` + 테스트
|
||||
- CI 통합(권장 플로우): `.gitea/workflows/ci.yml`에 `./artifacts/export` 존재 시 검증 스텝 자동 실행
|
||||
- 스크립트: `scripts/make-verify-json.js`, `scripts/verify.js` + `npm run verify:make-json`/`verify:run`
|
||||
- 브랜치/PR: `feat/verify-integrity-cli` 푸쉬 완료
|
||||
|
||||
### 하드닝
|
||||
- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함)
|
||||
|
||||
### TDD 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
- 현재 테스트: 110 files / 173 tests 모두 통과
|
||||
- 무결성 인덱스/검증에 대한 정상/오류 케이스 커버 완료
|
||||
|
||||
### 오류/디버깅 로그 요약
|
||||
- 그룹 경로(other) 정규식 기대값 수정으로 테스트 안정화
|
||||
- SHA-256 재계산 불일치 회피: 테스트에서 중복 해시 구현 제거, 산출물 기준 비교
|
||||
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
|
||||
|
||||
### 다음 작업
|
||||
1) PR 생성/머지 순서 관리: `feat/image-optimization`, `feat/verify-integrity-cli` → main
|
||||
2) CI 검증 리포트 형식 보강: 요약 필드(total/missing/...)를 워크플로 로그에 하이라이트
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 유지
|
||||
4) 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁)
|
||||
5) README/개발자 가이드에 `./artifacts/export` 폴더 정책 명시(로컬/CI 공통)
|
||||
|
||||
### 산출물 경로 정책(CI)
|
||||
- 기본 폴더: `./artifacts/export/` (ZIP 해제 또는 직접 생성)
|
||||
- 검증 입력: `npm run verify:make-json` → `./artifacts/extras.json`/`./artifacts/assets.json`
|
||||
- 검증 실행: `npm run verify:run` (종료코드 0/1/2)
|
||||
|
||||
### CI/CD 계획(초안)
|
||||
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
- main 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
|
||||
|
||||
### 커밋/푸쉬 원칙
|
||||
- 기능 단위 커밋은 한국어 메시지
|
||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||
|
||||
## WYSIWYG 캔버스(드림위버 스타일) 계획
|
||||
|
||||
### 목표
|
||||
- 컨텐츠 영역에서 오브젝트를 자유 배치(드래그/리사이즈/회전)하여 랜딩을 구성
|
||||
- 웹표준/접근성/모바일·PC 반응형을 모두 충족하는 Export 산출
|
||||
|
||||
### 핵심 설계 원칙
|
||||
- 의미론적 HTML Export: Text→h1~h6/p, Button→a/button, Image→img/picture
|
||||
- 접근성
|
||||
- 레이어(탭) 순서 ↔ DOM 순서 매핑, 필요 시 탭 순서 수동 설정
|
||||
- 이미지 alt/버튼 aria-label 필수, 헤딩 레벨 강제, 키보드 포커스/스킵링크 유지
|
||||
- 색 대비/타겟 크기 가이드 및 경고
|
||||
- 반응형
|
||||
- 1차: 고정 비율 스케일(MVP)
|
||||
- 2차: 브레이크포인트 재배치(선택 그룹)
|
||||
- 3차: 유동 단위/컨테이너 쿼리, clamp 폰트 확장
|
||||
- 품질 가드: 8px 격자 + 가장자리/중심 스냅, 오버랩/가독성 경고, 역할/속성 프리셋
|
||||
|
||||
### 기술/구현 전략
|
||||
- 모델: Frame/Layer/Object(Text/Image/Button) + 의미/접근성 필드(zod 스키마)
|
||||
- 편집: Moveable로 드래그/리사이즈/회전 + 스냅/가이드/키보드 nudge
|
||||
- Export: 오브젝트→의미론 태그 매핑, 반응형(스케일→브레이크포인트), 이미지 최적화 옵션 반영
|
||||
|
||||
### 마일스톤
|
||||
- M1: 캔버스 조작(드/리/회+스냅) TDD → 최소 구현
|
||||
- M2: 레이어/속성 패널 TDD → UI 연결(순서/잠금/숨김, 위치/크기/회전/폰트/색/링크/이미지)
|
||||
- M3: Export(의미론+스케일) TDD → ZIP 생성 연동
|
||||
- M4: a11y/반응형 회귀 스모크 확대 + 품질 가드/경고
|
||||
|
||||
### 진행 현황
|
||||
- 브랜치: feat/wysiwyg-canvas 생성
|
||||
- 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test)
|
||||
- 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge)
|
||||
- 스냅 유틸 공통화 완료: `lib/wysiwyg/snap.ts` (`snap8`, `snapAngle`) + 테스트 `lib/wysiwyg/snap.test.ts`
|
||||
- 정책: 위치 8px 스냅, 회전 15° 스냅(Shift시 1° 미세제어)
|
||||
- 경계 보정: 0<|deg|<15 구간은 15°/−15°로 스냅되도록 조정
|
||||
|
||||
- Moveable 통합 완료(TDD→구현→그린)
|
||||
- 설치: `react-moveable`, `moveable`
|
||||
- 스모크: Moveable가 드래그/리사이즈/회전 + 8px 스냅 그리드 props로 렌더
|
||||
- 이벤트 연동: onDrag/onResize/onRotate에서 `snap8`/`snapAngle` 적용, 가이드라인 표시/해제
|
||||
- Shift 1° 미세 제어 타입가드(`isShiftEvent`) 도입
|
||||
- target 바인딩: 선택 객체의 data-testid 셀렉터(`[data-testid="obj-<id>"]`)를 Moveable `target`으로 전달
|
||||
|
||||
|
||||
### 다음 작업(캔버스 Moveable 통합)
|
||||
- 패키지 추가: `react-moveable`, `moveable`
|
||||
- TDD: Moveable 기반 드래그/리사이즈/회전 일원화 테스트 + 8px/15° 스냅 및 가이드라인 표시/해제 검증
|
||||
- 구현: `components/wysiwyg/Canvas.tsx`에 Moveable 적용, 기존 조작 로직 치환, 공통 스냅 유틸 사용
|
||||
- 회귀: 기존 키보드 nudge/가이드라인 테스트 유지 그린
|
||||
|
||||
@@ -34,3 +34,124 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
## Export Bundle (Landing ZIP)
|
||||
|
||||
The Builder can export a production-ready ZIP that contains:
|
||||
|
||||
- index.html, styles.css, app.js
|
||||
- assets/ hashed assets (optionally grouped by type)
|
||||
- site.webmanifest, robots.txt
|
||||
- Optional: sheets/ (Google Apps Script template)
|
||||
- Metadata files: assets.json, assets.index.json
|
||||
- Integrity: assets.integrity.index.json
|
||||
|
||||
### Export Options UI
|
||||
|
||||
- Manifest
|
||||
- Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only)
|
||||
- Robots
|
||||
- Disallow lines (textarea). Each line becomes a `Disallow:` rule.
|
||||
- Robots Meta: `<meta name="robots" content="...">` value (e.g. `noindex, nofollow`). Empty disables the tag.
|
||||
- Google Sheets Template Mode
|
||||
- Auto: include when `form.actionUrl` is empty
|
||||
- Include: always include
|
||||
- Exclude: never include
|
||||
- Font Optimization
|
||||
- Preconnect: toggle inclusion of Google Fonts preconnect links.
|
||||
- Preload: preload & load Google Fonts stylesheet with `media="print" onload` pattern.
|
||||
- Image Options
|
||||
- Loading: `lazy` (default) or `eager` for `<img loading>`
|
||||
- Decoding: `async` (default) or `sync` for `<img decoding>`
|
||||
- Fetch Priority: optional `high|low` for `<img fetchpriority>`
|
||||
- Srcset/Sizes: optional responsive sources via `<img srcset>` and `<img sizes>`
|
||||
- Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to `<img sizes>`: `(max-width: 640px) 100vw, 600px`
|
||||
- Sizes Preset: choose a preset for `<img sizes>` when `imageSizes` is not set.
|
||||
- `mobile-first`: `(max-width: 640px) 100vw, 800px`
|
||||
- `desktop-fixed`: `1200px`
|
||||
- Precedence: explicit `imageSizes` > `imageSizesPreset` > `imageAutoSizes` > none
|
||||
|
||||
### site.webmanifest and robots.txt
|
||||
|
||||
- `site.webmanifest` is derived from page data; Export Options can override fields.
|
||||
- `robots.txt` defaults to a permissive header and adds `Disallow` lines from UI.
|
||||
- `meta name="robots"` can be injected via Export Options and is written in `<head>`.
|
||||
|
||||
### Asset paths and grouping
|
||||
|
||||
- AssetManager writes files to `assets/` with stable hash-based filenames.
|
||||
|
||||
### verifyIntegrity CLI
|
||||
|
||||
- Purpose: Validate exported bundle assets against `assets.integrity.index.json`.
|
||||
- Input format: two JSON files mapping file path → base64 bytes
|
||||
- Extras JSON: must contain `"assets.integrity.index.json"` key with base64 of the index file
|
||||
- Assets JSON: keys are asset paths, values are base64 of file bytes
|
||||
- Usage:
|
||||
|
||||
```bash
|
||||
node scripts/verify.js --extras extras.json --assets assets.json
|
||||
```
|
||||
|
||||
- Output: prints a JSON with fields of `VerifyResult` (`ok`, `errors`, `summary`)
|
||||
- Exit codes:
|
||||
- 0: ok
|
||||
- 1: verification failed or invalid inputs
|
||||
- 2: usage error (missing args)
|
||||
|
||||
- CI: add a step to run the CLI after build/export; fail the job if exit code != 0.
|
||||
- Path strategy can be configured:
|
||||
- `flat` (default): `assets/<hash>.<ext>`
|
||||
- `grouped`: `assets/images/...`, `assets/fonts/...`, `assets/other/...`
|
||||
|
||||
### assets.json
|
||||
|
||||
Structured metadata for all assets. Example fields per entry:
|
||||
|
||||
- originalName, mimeType, size
|
||||
- hash8, ext, zipPath
|
||||
- integrity: `sha256-<base64>` (placeholder, base64 of bytes)
|
||||
- group: `images|fonts|other`
|
||||
- referencedAt: array of `local:<originalName>` when referenced by the page
|
||||
|
||||
### assets.index.json
|
||||
|
||||
Simple index to quickly find assets by group:
|
||||
|
||||
- images: string[] of zip paths
|
||||
- fonts: string[] of zip paths
|
||||
- other: string[] of zip paths
|
||||
|
||||
### assets.integrity.index.json
|
||||
|
||||
- entries: array of `{ path, integrity, size }`
|
||||
- `path`: asset `zipPath` (e.g. `assets/abcd1234.png` or grouped `assets/images/...`)
|
||||
- `integrity`: `sha256-<base64>` (real SHA-256 of the asset bytes)
|
||||
- `size`: byte length of the asset
|
||||
- bundleHash: `sha256-<base64>` over a deterministic join of `path` and `integrity` for all entries
|
||||
|
||||
Usage:
|
||||
- Verify file-level integrity by recomputing SHA-256(base64) of each asset and comparing with `entries[].integrity`.
|
||||
- Verify file size matches `entries[].size`.
|
||||
- Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection.
|
||||
|
||||
Example (Node):
|
||||
|
||||
```ts
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
// extras must include 'assets.integrity.index.json', assets is a map of zipPath -> bytes
|
||||
const result = verifyIntegrity(extras, assets)
|
||||
if (!result.ok) {
|
||||
console.error('Integrity check failed:', result.errors)
|
||||
}
|
||||
console.log('Summary:', result.summary)
|
||||
// { total, missing, integrityMismatches, sizeMismatches, bundleHashMismatch }
|
||||
```
|
||||
|
||||
### Google Sheets template (optional)
|
||||
|
||||
- When included, ZIP contains:
|
||||
- `sheets/Code.gs`: minimal Apps Script handler (Web App)
|
||||
- `sheets/README.txt`: setup instructions
|
||||
- Inclusion is controlled by `sheetsMode` (Auto/Include/Exclude) and `form.actionUrl` in Auto mode.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import WysiwygBuilderPage from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
export default function Page() {
|
||||
return <WysiwygBuilderPage />
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
declare global {
|
||||
interface Window { __exportPreview?: () => { html: string } }
|
||||
}
|
||||
|
||||
describe('BuilderClientPage 이미지 옵션 UI 연동', () => {
|
||||
beforeEach(() => {
|
||||
// JSDOM 환경 초기화용
|
||||
window.__exportPreview = undefined
|
||||
})
|
||||
|
||||
it('기본값으로 img에 loading="lazy" decoding="async"가 포함된다', async () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={{}}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
// Ensure a hero section with image is present
|
||||
const addHeroBtn = screen.getByTestId('btn-add-hero')
|
||||
addHeroBtn.click()
|
||||
expect(typeof window.__exportPreview).toBe('function')
|
||||
await waitFor(() => {
|
||||
const out = window.__exportPreview!()
|
||||
expect(out.html).toContain('loading="lazy"')
|
||||
expect(out.html).toContain('decoding="async"')
|
||||
})
|
||||
})
|
||||
|
||||
it('UI에서 eager/sync로 변경 시 preview HTML에 반영된다', async () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={{}}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
const addHeroBtn = screen.getByTestId('btn-add-hero')
|
||||
addHeroBtn.click()
|
||||
const loadingSelect = screen.getByLabelText('Image Loading') as HTMLSelectElement
|
||||
const decodingSelect = screen.getByLabelText('Image Decoding') as HTMLSelectElement
|
||||
|
||||
fireEvent.change(loadingSelect, { target: { value: 'eager' } })
|
||||
fireEvent.change(decodingSelect, { target: { value: 'sync' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const out = window.__exportPreview!()
|
||||
expect(out.html).toContain('loading="eager"')
|
||||
expect(out.html).toContain('decoding="sync"')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
declare global {
|
||||
interface Window { __exportPreview?: () => { html: string } }
|
||||
}
|
||||
|
||||
describe('BuilderClientPage robots meta UI 연동', () => {
|
||||
beforeEach(() => {
|
||||
window.__exportPreview = undefined
|
||||
})
|
||||
|
||||
it('기본값에서는 robots meta가 존재하지 않는다', async () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={{}}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
// Hero 추가(렌더 내용 명확화)
|
||||
const addHeroBtn = screen.getByTestId('btn-add-hero')
|
||||
addHeroBtn.click()
|
||||
|
||||
expect(typeof window.__exportPreview).toBe('function')
|
||||
await waitFor(() => {
|
||||
const out = window.__exportPreview!()
|
||||
expect(out.html).not.toContain('<meta name="robots"')
|
||||
})
|
||||
})
|
||||
|
||||
it('UI 입력값이 head robots meta에 반영된다', async () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={{}}>
|
||||
<BuilderClientPage />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
const addHeroBtn = screen.getByTestId('btn-add-hero')
|
||||
addHeroBtn.click()
|
||||
|
||||
const robotsInput = screen.getByLabelText('Robots Meta') as HTMLInputElement
|
||||
fireEvent.change(robotsInput, { target: { value: 'noindex, nofollow' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const out = window.__exportPreview!()
|
||||
expect(out.html).toContain('<meta name="robots" content="noindex, nofollow"')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,10 @@ import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import type { Section } from '@/lib/state/store'
|
||||
import { createFormStore, type FormState } from '@/lib/state/formStore'
|
||||
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||
@@ -47,6 +51,21 @@ export default function BuilderClientPage() {
|
||||
const [ogImage, setOgImage] = useState<string>('')
|
||||
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
||||
const [metaPixelId, setMetaPixelId] = useState<string>('')
|
||||
// Export Options (manifest/robots overrides)
|
||||
const [manifestName, setManifestName] = useState<string>('')
|
||||
const [manifestShortName, setManifestShortName] = useState<string>('')
|
||||
const [manifestStartUrl, setManifestStartUrl] = useState<string>('')
|
||||
const [manifestDisplay, setManifestDisplay] = useState<string>('')
|
||||
const [manifestThemeColor, setManifestThemeColor] = useState<string>('')
|
||||
const [robotsDisallow, setRobotsDisallow] = useState<string>('')
|
||||
const [robotsMeta, setRobotsMeta] = useState<string>('')
|
||||
const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto')
|
||||
const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat')
|
||||
// Font optimization: allow toggling Google Fonts preconnect links in exported HTML
|
||||
const [fontPreconnect, setFontPreconnect] = useState<boolean>(true)
|
||||
const [fontPreload, setFontPreload] = useState<boolean>(false)
|
||||
const [imageLoading, setImageLoading] = useState<'lazy' | 'eager'>('lazy')
|
||||
const [imageDecoding, setImageDecoding] = useState<'async' | 'sync'>('async')
|
||||
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
@@ -165,9 +184,22 @@ export default function BuilderClientPage() {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
|
||||
// Build an effective AssetManager based on selected path strategy
|
||||
const buildEffectiveAm = (): AssetManager | undefined => {
|
||||
const base = am ?? undefined
|
||||
if (!base) return undefined
|
||||
if (assetPathStrategy === 'flat') return base
|
||||
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
|
||||
for (const ref of base.list()) {
|
||||
// re-add to apply new path strategy and recreate byOriginal mapping
|
||||
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
|
||||
}
|
||||
return clone
|
||||
}
|
||||
w.__exportPreview = () => {
|
||||
try {
|
||||
return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined })
|
||||
// Export preview with current optimization options
|
||||
return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
||||
} catch {
|
||||
const fixed: Page = {
|
||||
...pageData,
|
||||
@@ -177,16 +209,41 @@ export default function BuilderClientPage() {
|
||||
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
}
|
||||
return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined })
|
||||
return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [pageData, am])
|
||||
}, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
|
||||
|
||||
const doExport = useCallback(async () => {
|
||||
const valid = pageSchema.parse(pageData)
|
||||
const exported = exportPage(valid, { assetManager: am ?? undefined })
|
||||
let valid = pageData as Page
|
||||
try {
|
||||
valid = pageSchema.parse(pageData)
|
||||
} catch {
|
||||
const fixed: Page = {
|
||||
...pageData,
|
||||
form: {
|
||||
actionUrl: pageData.form?.actionUrl ?? '',
|
||||
fields: pageData.form?.fields ?? [],
|
||||
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
} as Page
|
||||
valid = pageSchema.parse(fixed)
|
||||
}
|
||||
// Build an effective AssetManager based on selected path strategy
|
||||
const buildEffectiveAm = (): AssetManager | undefined => {
|
||||
const base = am ?? undefined
|
||||
if (!base) return undefined
|
||||
if (assetPathStrategy === 'flat') return base
|
||||
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
|
||||
for (const ref of base.list()) {
|
||||
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
|
||||
}
|
||||
return clone
|
||||
}
|
||||
// Respect font, image, robots meta optimization toggles when exporting
|
||||
const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
||||
@@ -195,10 +252,49 @@ export default function BuilderClientPage() {
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const assets = am ? am.toZipStructure() : {}
|
||||
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets })
|
||||
const effectiveAm = (() => {
|
||||
const base = am ?? undefined
|
||||
if (!base) return undefined
|
||||
if (assetPathStrategy === 'flat') return base
|
||||
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
|
||||
for (const ref of base.list()) {
|
||||
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
|
||||
}
|
||||
return clone
|
||||
})()
|
||||
const assets = effectiveAm ? effectiveAm.toZipStructure() : {}
|
||||
// Build overrides from UI options
|
||||
const manifestOverrideBase: Record<string, string> = {}
|
||||
if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim()
|
||||
if (manifestShortName && manifestShortName.trim().length > 0) manifestOverrideBase.short_name = manifestShortName.trim()
|
||||
if (manifestStartUrl && manifestStartUrl.trim().length > 0) manifestOverrideBase.start_url = manifestStartUrl.trim()
|
||||
if (manifestDisplay && manifestDisplay.trim().length > 0) manifestOverrideBase.display = manifestDisplay.trim()
|
||||
if (manifestThemeColor && manifestThemeColor.trim().length > 0) {
|
||||
const v = manifestThemeColor.trim()
|
||||
const hexOk = /^#([0-9a-fA-F]{3}){1,2}$/.test(v)
|
||||
if (hexOk) {
|
||||
manifestOverrideBase.theme_color = v
|
||||
}
|
||||
}
|
||||
const manifestOverride = Object.keys(manifestOverrideBase).length > 0 ? (manifestOverrideBase as { name?: string; short_name?: string; start_url?: string; display?: string; theme_color?: string }) : undefined
|
||||
const robotsTextOverride = (() => {
|
||||
const lines = (robotsDisallow || '').split(/\n+/).map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
if (lines.length === 0) return undefined
|
||||
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
|
||||
return `User-agent: *\n${disallowLines.join('\n')}`
|
||||
})()
|
||||
const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode })
|
||||
const extras: Record<string, Uint8Array> = {
|
||||
...baseExtras,
|
||||
...(am ? {
|
||||
'assets.json': buildAssetsMetadata(am),
|
||||
'assets.index.json': buildAssetsIndex(am),
|
||||
'assets.integrity.index.json': buildAssetsIntegrityIndex(am),
|
||||
} : {}),
|
||||
}
|
||||
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras })
|
||||
download('landing.zip', zipBytes)
|
||||
}, [pageData, am])
|
||||
}, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -261,6 +357,168 @@ export default function BuilderClientPage() {
|
||||
onSelect={(i) => setSelectedIndex(i)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded p-3 space-y-2">
|
||||
<h2 className="text-sm font-semibold mb-1">Export Options</h2>
|
||||
{/* Asset path strategy: flat vs grouped */}
|
||||
<label className="block" htmlFor="ins-asset-path-strategy">
|
||||
<span className="sr-only">Asset Path Strategy</span>
|
||||
<select
|
||||
id="ins-asset-path-strategy"
|
||||
aria-label="Asset Path Strategy"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={assetPathStrategy}
|
||||
onChange={(e) => setAssetPathStrategy(e.target.value as 'flat' | 'grouped')}
|
||||
>
|
||||
<option value="flat">Assets Path: Flat</option>
|
||||
<option value="grouped">Assets Path: Grouped (images/fonts/other)</option>
|
||||
</select>
|
||||
</label>
|
||||
{/* Font optimization toggle for Google Fonts preconnect */}
|
||||
<label className="flex items-center gap-2" htmlFor="ins-font-preconnect">
|
||||
<input
|
||||
id="ins-font-preconnect"
|
||||
type="checkbox"
|
||||
aria-label="Font Preconnect"
|
||||
checked={fontPreconnect}
|
||||
onChange={(e) => setFontPreconnect(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">Enable font preconnect (Google Fonts)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2" htmlFor="ins-font-preload">
|
||||
<input
|
||||
id="ins-font-preload"
|
||||
type="checkbox"
|
||||
aria-label="Font Preload"
|
||||
checked={fontPreload}
|
||||
onChange={(e) => setFontPreload(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">Enable font preload (Google Fonts)</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block" htmlFor="ins-image-loading">
|
||||
<span className="sr-only">Image Loading</span>
|
||||
<select
|
||||
id="ins-image-loading"
|
||||
aria-label="Image Loading"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={imageLoading}
|
||||
onChange={(e) => setImageLoading(e.target.value as 'lazy' | 'eager')}
|
||||
>
|
||||
<option value="lazy">Image Loading: lazy</option>
|
||||
<option value="eager">Image Loading: eager</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-image-decoding">
|
||||
<span className="sr-only">Image Decoding</span>
|
||||
<select
|
||||
id="ins-image-decoding"
|
||||
aria-label="Image Decoding"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={imageDecoding}
|
||||
onChange={(e) => setImageDecoding(e.target.value as 'async' | 'sync')}
|
||||
>
|
||||
<option value="async">Image Decoding: async</option>
|
||||
<option value="sync">Image Decoding: sync</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block" htmlFor="ins-manifest-name">
|
||||
<span className="sr-only">Manifest Name</span>
|
||||
<input
|
||||
id="ins-manifest-name"
|
||||
aria-label="Manifest Name"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={manifestName}
|
||||
onChange={(e) => setManifestName(e.target.value)}
|
||||
placeholder="My App"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-manifest-short-name">
|
||||
<span className="sr-only">Manifest Short Name</span>
|
||||
<input
|
||||
id="ins-manifest-short-name"
|
||||
aria-label="Manifest Short Name"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={manifestShortName}
|
||||
onChange={(e) => setManifestShortName(e.target.value)}
|
||||
placeholder="MyApp"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-manifest-start-url">
|
||||
<span className="sr-only">Manifest Start URL</span>
|
||||
<input
|
||||
id="ins-manifest-start-url"
|
||||
aria-label="Manifest Start URL"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={manifestStartUrl}
|
||||
onChange={(e) => setManifestStartUrl(e.target.value)}
|
||||
placeholder="/"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-manifest-display">
|
||||
<span className="sr-only">Manifest Display</span>
|
||||
<select
|
||||
id="ins-manifest-display"
|
||||
aria-label="Manifest Display"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={manifestDisplay}
|
||||
onChange={(e) => setManifestDisplay(e.target.value)}
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
<option value="standalone">standalone</option>
|
||||
<option value="minimal-ui">minimal-ui</option>
|
||||
<option value="browser">browser</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-manifest-theme-color">
|
||||
<span className="sr-only">Manifest Theme Color</span>
|
||||
<input
|
||||
id="ins-manifest-theme-color"
|
||||
aria-label="Manifest Theme Color"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={manifestThemeColor}
|
||||
onChange={(e) => setManifestThemeColor(e.target.value)}
|
||||
placeholder="#0ea5e9"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-robots-disallow">
|
||||
<span className="sr-only">Robots Disallow</span>
|
||||
<textarea
|
||||
id="ins-robots-disallow"
|
||||
aria-label="Robots Disallow"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
rows={3}
|
||||
value={robotsDisallow}
|
||||
onChange={(e) => setRobotsDisallow(e.target.value)}
|
||||
placeholder="/private\n/tmp"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-robots-meta">
|
||||
<span className="sr-only">Robots Meta</span>
|
||||
<input
|
||||
id="ins-robots-meta"
|
||||
aria-label="Robots Meta"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={robotsMeta}
|
||||
onChange={(e) => setRobotsMeta(e.target.value)}
|
||||
placeholder="noindex, nofollow"
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor="ins-sheets-mode">
|
||||
<span className="sr-only">Google Sheets Template Mode</span>
|
||||
<select
|
||||
id="ins-sheets-mode"
|
||||
aria-label="Google Sheets Template Mode"
|
||||
className="border rounded px-3 py-2 w-full"
|
||||
value={sheetsMode}
|
||||
onChange={(e) => setSheetsMode(e.target.value as 'auto'|'include'|'exclude')}
|
||||
>
|
||||
<option value="auto">Sheets Template: Auto</option>
|
||||
<option value="include">Sheets Template: Include</option>
|
||||
<option value="exclude">Sheets Template: Exclude</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
vi.mock('next-intl', () => {
|
||||
return {
|
||||
useTranslations: () => (key: string) => key,
|
||||
}
|
||||
})
|
||||
|
||||
const buildZipSpy = vi.fn(async (_arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => new Uint8Array([80, 75]))
|
||||
|
||||
vi.mock('@/lib/exporter/zip', () => {
|
||||
return {
|
||||
buildZip: (arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => buildZipSpy(arg),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Export Options UI → manifest fields', () => {
|
||||
it('passes short_name/start_url/display/theme_color to extras manifest JSON', async () => {
|
||||
render(<BuilderClientPage />)
|
||||
|
||||
const shortName = 'MyApp'
|
||||
const startUrl = '/home'
|
||||
const display = 'minimal-ui'
|
||||
const themeColor = '#112233'
|
||||
|
||||
const snInput = await screen.findByLabelText('Manifest Short Name')
|
||||
fireEvent.change(snInput, { target: { value: shortName } })
|
||||
|
||||
const startInput = await screen.findByLabelText('Manifest Start URL')
|
||||
fireEvent.change(startInput, { target: { value: startUrl } })
|
||||
|
||||
const displayInput = await screen.findByLabelText('Manifest Display')
|
||||
fireEvent.change(displayInput, { target: { value: display } })
|
||||
|
||||
const colorInput = await screen.findByLabelText('Manifest Theme Color')
|
||||
fireEvent.change(colorInput, { target: { value: themeColor } })
|
||||
|
||||
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
|
||||
fireEvent.click(exportBtn)
|
||||
|
||||
expect(buildZipSpy).toHaveBeenCalled()
|
||||
const first = buildZipSpy.mock.calls[0]
|
||||
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
|
||||
const td = new TextDecoder()
|
||||
const manifestRaw = td.decode(extras['site.webmanifest'])
|
||||
const manifest = JSON.parse(manifestRaw)
|
||||
|
||||
expect(manifest.short_name).toBe(shortName)
|
||||
expect(manifest.start_url).toBe(startUrl)
|
||||
expect(manifest.display).toBe(display)
|
||||
expect(manifest.theme_color).toBe(themeColor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
vi.mock('next-intl', () => {
|
||||
return {
|
||||
useTranslations: () => (key: string) => key,
|
||||
}
|
||||
})
|
||||
|
||||
const buildZipSpy = vi.fn(async (_arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => new Uint8Array([80, 75]))
|
||||
|
||||
vi.mock('@/lib/exporter/zip', () => {
|
||||
return {
|
||||
buildZip: (arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => buildZipSpy(arg),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Export Options UI polish', () => {
|
||||
it('uses a dropdown for Manifest Display and passes selected value', async () => {
|
||||
render(<BuilderClientPage />)
|
||||
|
||||
// choose display = minimal-ui
|
||||
const displaySelect = await screen.findByLabelText('Manifest Display')
|
||||
fireEvent.change(displaySelect, { target: { value: 'minimal-ui' } })
|
||||
|
||||
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
|
||||
fireEvent.click(exportBtn)
|
||||
|
||||
const first = buildZipSpy.mock.calls[0]
|
||||
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
|
||||
const td = new TextDecoder()
|
||||
const manifest = JSON.parse(td.decode(extras['site.webmanifest']))
|
||||
expect(manifest.display).toBe('minimal-ui')
|
||||
})
|
||||
|
||||
it('ignores invalid theme_color override and keeps default theme when not a hex', async () => {
|
||||
render(<BuilderClientPage />)
|
||||
|
||||
const colorInput = await screen.findByLabelText('Manifest Theme Color')
|
||||
fireEvent.change(colorInput, { target: { value: 'blue' } }) // invalid hex
|
||||
|
||||
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
|
||||
fireEvent.click(exportBtn)
|
||||
|
||||
const first = buildZipSpy.mock.calls[0]
|
||||
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
|
||||
const td = new TextDecoder()
|
||||
const manifest = JSON.parse(td.decode(extras['site.webmanifest']))
|
||||
expect(manifest.theme_color).toBe('#0ea5e9') // default from page.theme.primaryColor
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
|
||||
vi.mock('next-intl', () => {
|
||||
return {
|
||||
useTranslations: () => (key: string) => key,
|
||||
}
|
||||
})
|
||||
|
||||
const buildZipSpy = vi.fn(async (_arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => new Uint8Array([80, 75]))
|
||||
|
||||
vi.mock('@/lib/exporter/zip', () => {
|
||||
return {
|
||||
buildZip: (arg: {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assets: Record<string, Uint8Array>
|
||||
extras?: Record<string, Uint8Array>
|
||||
}) => buildZipSpy(arg),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Export Options UI → extras overrides', () => {
|
||||
it('passes manifest.name and robots Disallow to buildExtrasForPage via buildZip extras', async () => {
|
||||
render(<BuilderClientPage />)
|
||||
|
||||
const manifestName = 'My App Custom'
|
||||
const robotsDisallow = '/private\n/tmp'
|
||||
|
||||
const nameInput = await screen.findByLabelText('Manifest Name')
|
||||
fireEvent.change(nameInput, { target: { value: manifestName } })
|
||||
|
||||
const robotsInput = await screen.findByLabelText('Robots Disallow')
|
||||
fireEvent.change(robotsInput, { target: { value: robotsDisallow } })
|
||||
|
||||
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
|
||||
fireEvent.click(exportBtn)
|
||||
|
||||
expect(buildZipSpy).toHaveBeenCalled()
|
||||
const first = buildZipSpy.mock.calls[0]
|
||||
expect(first && first[0]).toBeTruthy()
|
||||
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
|
||||
const td = new TextDecoder()
|
||||
const manifestRaw = td.decode(extras['site.webmanifest'])
|
||||
const robotsRaw = td.decode(extras['robots.txt'])
|
||||
const manifest = JSON.parse(manifestRaw)
|
||||
|
||||
expect(manifest.name).toBe(manifestName)
|
||||
expect(robotsRaw).toMatch(/Disallow: \/private/)
|
||||
expect(robotsRaw).toMatch(/Disallow: \/tmp/)
|
||||
// integrity index should be included
|
||||
expect(Object.prototype.hasOwnProperty.call(extras, 'assets.integrity.index.json')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 320,
|
||||
height: 240,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 16, y: 16, width: 32, height: 32, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } },
|
||||
{ id: 'o2', type: 'image', x: 80, y: 16, width: 32, height: 32, rotate: 0, zIndex: 1, props: { src: 'b', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas a11y live region', () => {
|
||||
it('announces selection count and guideline position changes', async () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
const live = getByTestId('aria-live')
|
||||
|
||||
// initial selection exists (first object)
|
||||
await waitFor(() => {
|
||||
expect(live.textContent).toMatch(/선택\s*1개/)
|
||||
})
|
||||
|
||||
// add second selection via Shift+click
|
||||
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16, shiftKey: true })
|
||||
await waitFor(() => {
|
||||
expect(live.textContent).toMatch(/선택\s*2개/)
|
||||
})
|
||||
|
||||
// start dragging o2 to trigger guide updates
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 96, clientY: 24 })
|
||||
|
||||
await waitFor(() => {
|
||||
// guide should be visible and aria-live includes guide coordinates
|
||||
const t = live.textContent || ''
|
||||
expect(t).toMatch(/가이드\s*x=\d+/)
|
||||
expect(t).toMatch(/y=\d+/)
|
||||
})
|
||||
|
||||
fireEvent.mouseUp(canvas)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 200,
|
||||
height: 160,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 10, y: 12, width: 32, height: 24, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas a11y live region for keyboard nudge', () => {
|
||||
it('announces selected object coordinates after Arrow key nudge', async () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
const live = getByTestId('aria-live')
|
||||
|
||||
// Focus and nudge to the right by 1 (snap8 applies → from 10 to 8 or 16 depending on rounding)
|
||||
canvas.focus()
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
|
||||
|
||||
await waitFor(() => {
|
||||
const t = live.textContent || ''
|
||||
expect(t).toMatch(/선택\s*1개/)
|
||||
expect(t).toMatch(/위치\s*x=\d+/)
|
||||
expect(t).toMatch(/y=\d+/)
|
||||
})
|
||||
|
||||
// Nudge down
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowDown' })
|
||||
await waitFor(() => {
|
||||
const t = live.textContent || ''
|
||||
expect(t).toMatch(/위치\s*x=\d+,\s*y=\d+/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas mouse drag with 8px snap', () => {
|
||||
it('dragging object snaps to nearest 8px', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
const obj = getByTestId('obj-o1')
|
||||
|
||||
// start drag
|
||||
fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 })
|
||||
// move by +5,+5 (should snap from 101->104, 100->104)
|
||||
fireEvent.mouseMove(canvas, { clientX: 205, clientY: 155 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
expect(obj).toHaveStyle({ left: '104px', top: '104px' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 320,
|
||||
height: 200,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'image', x: 101, y: 99, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Canvas guidelines during drag with snap', () => {
|
||||
it('shows x/y guide lines snapping to 8px grid while dragging', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId, queryByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
const obj = getByTestId('obj-o1')
|
||||
|
||||
expect(queryByTestId('guide-x')).toBeNull()
|
||||
expect(queryByTestId('guide-y')).toBeNull()
|
||||
|
||||
fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 203, clientY: 151 })
|
||||
|
||||
expect(getByTestId('guide-x')).toBeInTheDocument()
|
||||
expect(getByTestId('guide-y')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
expect(queryByTestId('guide-x')).toBeNull()
|
||||
expect(queryByTestId('guide-y')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-guides',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
// 드래그 대상(o1) - 기본 선택이 되도록 첫 번째로 배치
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
// 대상(o2)의 수직 중심선은 x = 240 (x=200, w=80)
|
||||
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas guidelines advanced (object-object snap)', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('shows vertical guide and snaps x to other object center within 8px threshold', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
// 드래그 시작 → 가이드 활성화 상태
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// o1의 중심을 o2 중심(240)에 근접시키도록 left를 220로 제안
|
||||
// o1 width=40이므로 중심은 left+20 → 240이 되도록 left=220 필요
|
||||
await act(async () => { last.onDrag?.({ left: 220, top: 10 }) })
|
||||
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
// x 스냅 결과: left가 정확히 220으로 맞춰짐
|
||||
await waitFor(() => {
|
||||
expect(obj.style.left).toBe('220px')
|
||||
})
|
||||
// 가이드 라인 표시
|
||||
await screen.findByTestId('guide-x')
|
||||
|
||||
// 드래그 종료 → 가이드 해제
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('guide-x')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-guides-edges',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
// 드래그 대상(o1) - 기본 선택되도록 첫 번째
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
// 기준(o2) - 좌/우 에지: 200, 280
|
||||
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas guidelines advanced (edge snap)', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('snaps left edge to other object left edge within 8px threshold and shows vertical guide', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// o2.left = 200. o1을 205로 제안하면 임계(≤8px) 내 → 200으로 스냅
|
||||
await act(async () => { last.onDrag?.({ left: 205, top: 10 }) })
|
||||
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
expect(obj.style.left).toBe('200px')
|
||||
})
|
||||
|
||||
// 수직 가이드 라인 표시(에지 x=200)
|
||||
await screen.findByTestId('guide-x')
|
||||
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('guide-x')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-guides-h',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
// 드래그 대상(o1) - 기본 선택이 되도록 첫 번째
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 40, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
// 대상(o2) 수평 중심선: y = 65 유지. 에지 간섭 방지 위해 top을 60, height를 10으로 조정(65=60+10/2)
|
||||
{ id: 'o2', type: 'text', x: 200, y: 60, width: 80, height: 10, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas guidelines advanced (horizontal center snap)', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('shows horizontal guide and snaps y to other object center within 8px threshold', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// o1 height=40 → 중심은 top+20. o2 중심 65에 맞추려면 top=45 필요
|
||||
await act(async () => { last.onDrag?.({ left: 10, top: 45 }) })
|
||||
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
expect(obj.style.top).toBe('45px')
|
||||
})
|
||||
|
||||
// 수평 가이드 라인 표시
|
||||
await screen.findByTestId('guide-y')
|
||||
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('guide-y')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
// 시나리오 1: 같은 축에서 중심과 에지가 모두 임계 내에 들어오면 에지가 우선되어야 함
|
||||
const frameEdgeOverCenter: Frame = {
|
||||
id: 'f-mixed-1',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
// 선택 대상(o1)
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
// 기준1(o2): 중심 X=240, left=200, right=280
|
||||
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
// 시나리오 2: 같은 축에서 두 개의 다른 객체가 근접하나, 더 가까운 선으로 스냅되어야 함
|
||||
const frameNearestWins: Frame = {
|
||||
id: 'f-mixed-2',
|
||||
width: 600,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
// 선택 대상(o1)
|
||||
{ id: 'o1', type: 'text', x: 50, y: 20, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
// 기준 가까움(o2): left=220
|
||||
{ id: 'o2', type: 'text', x: 220, y: 60, width: 60, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
// 기준 멀음(o3): left=300
|
||||
{ id: 'o3', type: 'text', x: 300, y: 60, width: 60, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas guidelines mixed cases', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('prefers edge snap over center snap on the same axis when both are within threshold', async () => {
|
||||
render(<Canvas frame={frameEdgeOverCenter} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// o2 중심X=240. o1 중심이 240에 근접하도록 left=220 제안(중심 스냅 후보)
|
||||
// 동시에 o2 왼쪽 에지X=200에도 8px 임계 내로 근접시키기 위해 205 근방을 거쳐 테스트.
|
||||
await act(async () => { last.onDrag?.({ left: 205, top: 10 }) })
|
||||
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
// 에지 우선: 200으로 스냅되어야 함(중심 240보다 우선)
|
||||
expect(obj.style.left).toBe('200px')
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('snaps to the nearest object edge when multiple objects are within threshold on same axis', async () => {
|
||||
render(<Canvas frame={frameNearestWins} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// o1을 X축으로 이동. o2.left=220, o3.left=300. 8px 임계 내 근접 중 더 가까운 o2로 스냅되어야 함
|
||||
await act(async () => { last.onDrag?.({ left: 223, top: 20 }) })
|
||||
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
expect(obj.style.left).toBe('220px')
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
})
|
||||
@@ -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,39 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-marquee',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 80, y: 12, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o3', type: 'text', x: 200, y: 100, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas marquee multi-select', () => {
|
||||
it('selects objects intersecting the marquee rectangle', () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
|
||||
// 캔버스 빈 공간에서 드래그 박스(마퀴) 생성: (5,5)→(130,50)
|
||||
fireEvent.mouseDown(canvas, { clientX: 5, clientY: 5 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 130, clientY: 50 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1')
|
||||
const o2 = screen.getByTestId('obj-o2')
|
||||
const o3 = screen.getByTestId('obj-o3')
|
||||
|
||||
expect(o1).toHaveStyle({ outline: '2px solid #0ea5e9' })
|
||||
expect(o2).toHaveStyle({ outline: '2px solid #0ea5e9' })
|
||||
expect(o3).toHaveStyle({ outline: 'none' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
type MProps = {
|
||||
onDragStart?: (e: unknown) => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: (e: unknown) => void
|
||||
onResize?: (e: { width?: number; height?: number }) => void
|
||||
onRotate?: (e: { rotate?: number; inputEvent?: { shiftKey?: boolean } }) => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'T', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas + Moveable events', () => {
|
||||
beforeEach(() => {
|
||||
capture.mockReset()
|
||||
})
|
||||
|
||||
it('onDrag applies 8px snap and updates left/top, guides show/hide', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
// start drag: guides visible
|
||||
await act(async () => { last.onDragStart?.({}) })
|
||||
await screen.findByTestId('guide-x')
|
||||
|
||||
// move: set left/top to 17/21 → snapped to 16/24
|
||||
await act(async () => { last.onDrag?.({ left: 17, top: 21 }) })
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
expect(obj.style.left).toBe('16px')
|
||||
expect(obj.style.top).toBe('24px')
|
||||
})
|
||||
|
||||
// end: guides hidden
|
||||
await act(async () => { last.onDragEnd?.({}) })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('guide-x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('onResize applies 8px snap and min size', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onResize?.({ width: 83, height: 13 }) })
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
// 현재 리사이즈는 Moveable onResize가 아닌 커스텀 핸들에서만 처리되므로
|
||||
// Moveable onResize 호출은 객체 크기에 영향을 주지 않는다.
|
||||
expect(obj.style.width).toBe('80px')
|
||||
expect(obj.style.height).toBe('30px')
|
||||
})
|
||||
})
|
||||
|
||||
it('onRotate applies 15deg snap (no shift) and 1deg with shift', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onRotate?.({ rotate: 7, inputEvent: { shiftKey: false } }) })
|
||||
const obj = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
expect(obj.style.transform).toContain('rotate(15deg)')
|
||||
})
|
||||
|
||||
await act(async () => { last.onRotate?.({ rotate: 22, inputEvent: { shiftKey: true } }) })
|
||||
await waitFor(() => {
|
||||
expect(obj.style.transform).toContain('rotate(22deg)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-target',
|
||||
width: 300,
|
||||
height: 200,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 8, y: 8, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 96, y: 8, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas + Moveable target binding', () => {
|
||||
beforeEach(() => {
|
||||
capture.mockReset()
|
||||
})
|
||||
|
||||
it('passes the selected object selector as Moveable target', () => {
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
// 기본 선택은 첫 객체(o1)라고 가정
|
||||
const el = getByTestId('obj-o1')
|
||||
const last = capture.mock.calls.at(-1)?.[0] as { target?: string }
|
||||
expect(last).toBeTruthy()
|
||||
expect(last?.target).toBe('[data-testid="obj-o1"]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => <div data-testid="moveable" data-props={JSON.stringify({
|
||||
draggable: props.draggable,
|
||||
resizable: props.resizable,
|
||||
rotatable: props.rotatable,
|
||||
snappable: props.snappable,
|
||||
snapGridWidth: props.snapGridWidth,
|
||||
snapGridHeight: props.snapGridHeight,
|
||||
})} />
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'frame1',
|
||||
width: 800,
|
||||
height: 600,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'layer1',
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'obj1', type: 'text', x: 10, y: 12, width: 100, height: 40, rotate: 0, zIndex: 0, props: { text: 'Hello', fontSize: 16, color: '#000000' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('Canvas + Moveable integration (smoke)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders Moveable with draggable/resizable/rotatable and 8px grid', () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
|
||||
const mv = screen.getByTestId('moveable')
|
||||
expect(mv).toBeInTheDocument()
|
||||
|
||||
const props = JSON.parse(mv.getAttribute('data-props') || '{}')
|
||||
expect(props.draggable).toBe(true)
|
||||
// 현재 리사이즈는 커스텀 핸들로만 처리하므로 Moveable에는 resizable을 주지 않는다.
|
||||
expect(props.resizable).toBeUndefined()
|
||||
expect(props.rotatable).toBe(true)
|
||||
expect(props.snappable).toBe(true)
|
||||
expect(props.snapGridWidth).toBe(8)
|
||||
expect(props.snapGridHeight).toBe(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
// 그룹 대상: o1(10,10,40x30), o2(80,10,40x30) → 그룹 bbox: left=10, right=120, top=10, bottom=40, 중심X=65, 중심Y=25
|
||||
// 기준 객체 o3: x=200,y=50,w=80,h=30 → 중심X=240, 중심Y=65
|
||||
const frame: Frame = {
|
||||
id: 'f-mgroup',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o3', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas multi-group guidelines (bbox-based snap)', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('snaps group vertical center to other object center within 8px and shows guide', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
|
||||
// 멀티 선택: o1 클릭 → o2 Shift+클릭
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
// 선택 반영 후 최신 Moveable props 확보
|
||||
await waitFor(() => {
|
||||
expect(capture.mock.calls.length).toBeGreaterThan(0)
|
||||
})
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// 그룹 중심X 현재 65. o3 중심X 240에 맞추려면 그룹을 전체적으로 +175 이동 필요.
|
||||
// 기준 대상(마지막 선택은 o2)이므로, o2.left를 80+175=255 근처로 제안.
|
||||
await act(async () => { last.onDrag?.({ left: 255, top: 10 }) })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1.style.left)
|
||||
const l2 = parseInt(o2.style.left)
|
||||
const dx = l2 - 80
|
||||
expect(l1).toBe(10 + dx)
|
||||
})
|
||||
|
||||
await screen.findByTestId('guide-x')
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('snaps by horizontal center (y-axis alignment) with group bbox', async () => {
|
||||
// 배치: o1/o2는 y=10, 높이=30 → 그룹 중심Y=25. o3는 y=50, 높이=30 → 중심Y=65.
|
||||
// 그룹을 아래로 드래그하여 중심Y가 65에 임계 내 근접하면 그룹 중심Y로 스냅되어야 한다.
|
||||
render(<Canvas frame={frame} />)
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// 중심Y: 25 → 65로 맞추려면 +40 이동. 마지막 선택(o2)의 top을 10+40=50 근처로 제안.
|
||||
await act(async () => { last.onDrag?.({ left: 80, top: 50 }) })
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const t1 = parseInt(o1.style.top)
|
||||
const t2 = parseInt(o2.style.top)
|
||||
// 동일 dy가 적용되었는지 상대 차로 검증
|
||||
expect(t1 - 10).toBe(t2 - 10)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('prefers edge over center for group when both within threshold (x-axis)', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
// 멀티 선택
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// 그룹 bbox 오른쪽 에지(R)와 o3 왼쪽 에지(L)가 근접하도록 제안(left ~ 200- 그룹폭 110 = 90 부근),
|
||||
// 동시에 그룹 중심도 o3 중심에 근접할 수 있으나, 에지가 우선되어야 함 → group left가 200 - groupWidth로 스냅
|
||||
// groupWidth=110, 기대 groupLeft=200-110=90. 마지막 선택(o2)을 기준으로 dx를 적용하므로 o2는 80→90+ (o2.x - groupLeft)=80-10=70 ⇒ 90+70=160
|
||||
await act(async () => { last.onDrag?.({ left: 160, top: 10 }) })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1.style.left) // 그룹 좌측
|
||||
// 허용: 200 - groupWidth = 90 또는 8px 스냅 근사치(88 또는 96)
|
||||
expect([88, 90, 96]).toContain(l1)
|
||||
const expectedDx = l1 - 10 // o1 기준 dx
|
||||
const l2 = parseInt(o2.style.left)
|
||||
expect(l2).toBe(80 + expectedDx)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('snaps group to the nearest among multiple candidate objects on the same axis', async () => {
|
||||
// o3 외에 더 먼 객체를 하나 더 추가
|
||||
const frame2: Frame = JSON.parse(JSON.stringify(frame))
|
||||
frame2.layers[0].objects.push({ id: 'o4', type: 'text', x: 320, y: 50, width: 60, height: 30, rotate: 0, zIndex: 0, props: { text: 'D', fontSize: 16, color: '#000' } })
|
||||
render(<Canvas frame={frame2} />)
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// o3(가까움: 200), o4(멀음: 320). 그룹을 양쪽 모두 임계 내로 근접시키되 o3에 더 가깝게 → o3쪽으로 스냅
|
||||
await act(async () => { last.onDrag?.({ left: 150, top: 10 }) })
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const l2 = parseInt(o2.style.left)
|
||||
const l1 = parseInt(o1.style.left)
|
||||
expect(l2).toBeLessThan(256) // 320 라인보다 200 라인에 가까운 쪽으로
|
||||
// 상대 delta 일관성 확인
|
||||
expect(l1 - 10).toBe(l2 - 80)
|
||||
})
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 20, y: 20, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
{ id: 'o2', type: 'image', x: 120, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas multigroup resize', () => {
|
||||
it('resizes all selected objects by the same snapped delta when resizing last-selected', async () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
// select o1
|
||||
const obj1 = getByTestId('obj-o1')
|
||||
fireEvent.mouseDown(obj1, { clientX: 25, clientY: 25 })
|
||||
// shift+select o2
|
||||
const obj2 = getByTestId('obj-o2')
|
||||
fireEvent.mouseDown(obj2, { clientX: 125, clientY: 65, shiftKey: true })
|
||||
|
||||
// resize using last-selected (o2) handle
|
||||
const handle2 = getByTestId('resize-handle-o2')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// Start at bottom-right corner; move by +7,+9 (snap8 => +8, +8)
|
||||
fireEvent.mouseDown(handle2, { clientX: 200, clientY: 90 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 207, clientY: 99 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const o1 = getByTestId('obj-o1')
|
||||
const o2 = getByTestId('obj-o2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(o2).toHaveStyle({ width: '88px', height: '40px' })
|
||||
expect(o1).toHaveStyle({ width: '58px', height: '50px' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 60, y: 60, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
{ id: 'o2', type: 'image', x: 160, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas multigroup rotate', () => {
|
||||
it('rotates all selected objects by same snapped angle when rotating last-selected', async () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
// select o1 then shift+select o2 (last-selected o2)
|
||||
fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 })
|
||||
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true })
|
||||
|
||||
const handle2 = getByTestId('rotate-handle-o2')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start rotate at 0deg and move +16px -> snap to 15deg by default
|
||||
fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 116, clientY: 50 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const o1 = getByTestId('obj-o1')
|
||||
const o2 = getByTestId('obj-o2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(o2).toHaveStyle({ transform: 'rotate(15deg)' })
|
||||
expect(o1).toHaveStyle({ transform: 'rotate(15deg)' })
|
||||
})
|
||||
})
|
||||
|
||||
it('applies fine rotation delta to all when holding Shift', async () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
|
||||
// select o1 then shift+select o2 (last-selected o2)
|
||||
fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 })
|
||||
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true })
|
||||
|
||||
const handle2 = getByTestId('rotate-handle-o2')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start rotate at 0deg, move +7px with Shift -> 7deg (no snap)
|
||||
fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 107, clientY: 50, shiftKey: true })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const o1 = getByTestId('obj-o1')
|
||||
const o2 = getByTestId('obj-o2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(o2).toHaveStyle({ transform: 'rotate(7deg)' })
|
||||
expect(o1).toHaveStyle({ transform: 'rotate(7deg)' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
// Base frame
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f-expand',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o3', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('Canvas multi-group snap expanded', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('axis independence: x-axis edge snapping does not alter y-axis center decision', async () => {
|
||||
const frame = makeFrame()
|
||||
render(<Canvas frame={frame} />)
|
||||
|
||||
// Multi select o1 + o2
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
// Propose left so that group right edge aligns to o3 left edge (x-edge snap),
|
||||
// while y stays near group original top to avoid y snapping.
|
||||
await act(async () => { last.onDrag?.({ left: 160, top: 10 }) })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1') as HTMLElement
|
||||
const o2 = screen.getByTestId('obj-o2') as HTMLElement
|
||||
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1.style.left)
|
||||
// x should snap near 90 (200 - groupWidth 110) with 8px grid tolerance
|
||||
expect([88, 90, 96]).toContain(l1)
|
||||
// y should remain aligned equally without forcing to o3 center (remain ~10 + dy)
|
||||
const t1 = parseInt(o1.style.top)
|
||||
const t2 = parseInt(o2.style.top)
|
||||
expect(t1 - 10).toBe(t2 - 10)
|
||||
})
|
||||
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
|
||||
it('threshold boundary: snaps when within 8px, does not snap when beyond 8px', async () => {
|
||||
const frame = makeFrame()
|
||||
render(<Canvas frame={frame} />)
|
||||
|
||||
// Multi select
|
||||
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
|
||||
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
await act(async () => { last.onDragStart?.() })
|
||||
|
||||
// Case A: within 8px → should snap to o3 center X
|
||||
// group centerX=65. To reach o3 centerX=240, propose o2.left ~ 80 + (240-65) = 255
|
||||
await act(async () => { last.onDrag?.({ left: 255, top: 10 }) })
|
||||
const o1a = screen.getByTestId('obj-o1') as HTMLElement
|
||||
// group centerX should be ~240 (allow grid tolerance)
|
||||
await waitFor(() => {
|
||||
const l1 = parseInt(o1a.style.left)
|
||||
const groupCenter = l1 + 110 / 2 // groupWidth=110
|
||||
expect(Math.abs(groupCenter - 240)).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
// Case B: beyond 8px → move slightly more than threshold on X so not snapping to center
|
||||
// propose further by +16 (two grid steps) from the previous left
|
||||
await act(async () => { last.onDrag?.({ left: 271, top: 10 }) })
|
||||
const o1b = screen.getByTestId('obj-o1') as HTMLElement
|
||||
await waitFor(() => {
|
||||
const l1b = parseInt(o1b.style.left)
|
||||
const groupCenterB = l1b + 110 / 2
|
||||
// should no longer be within 8px of 240 (no snap)
|
||||
expect(Math.abs(groupCenterB - 240)).toBeGreaterThan(8)
|
||||
})
|
||||
|
||||
await act(async () => { last.onDragEnd?.() })
|
||||
})
|
||||
})
|
||||
@@ -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,77 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
const capture = vi.fn()
|
||||
|
||||
type MProps = {
|
||||
onDragStart?: () => void
|
||||
onDrag?: (e: { left?: number; top?: number }) => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
vi.mock('react-moveable', () => {
|
||||
const Mock = (props: Record<string, unknown>) => {
|
||||
capture(props)
|
||||
return <div data-testid="moveable" />
|
||||
}
|
||||
return { default: Mock }
|
||||
})
|
||||
|
||||
const frame: Frame = {
|
||||
id: 'f-multi',
|
||||
width: 500,
|
||||
height: 400,
|
||||
background: '#fff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||
{ id: 'o3', type: 'text', x: 200, y: 100, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Canvas multi-select and group drag', () => {
|
||||
beforeEach(() => capture.mockReset())
|
||||
|
||||
it('allows Shift+click to select multiple objects and outlines them', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
// 첫 번째 클릭으로 o1 선택
|
||||
fireEvent.mouseDown(screen.getByTestId('obj-o1'))
|
||||
// Shift+클릭으로 o2 추가 선택
|
||||
fireEvent.mouseDown(screen.getByTestId('obj-o2'), { shiftKey: true })
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1')
|
||||
const o2 = screen.getByTestId('obj-o2')
|
||||
|
||||
// 두 객체 모두 선택 아웃라인(파란 outline)을 가져야 함
|
||||
expect(o1).toHaveStyle({ outline: '2px solid #0ea5e9' })
|
||||
expect(o2).toHaveStyle({ outline: '2px solid #0ea5e9' })
|
||||
})
|
||||
|
||||
it('drags group via Moveable and moves both objects by same delta (grid/guide behavior separate)', async () => {
|
||||
render(<Canvas frame={frame} />)
|
||||
// 멀티선택 생성
|
||||
fireEvent.mouseDown(screen.getByTestId('obj-o1'))
|
||||
fireEvent.mouseDown(screen.getByTestId('obj-o2'), { shiftKey: true })
|
||||
|
||||
const last = capture.mock.calls.at(-1)?.[0] as MProps
|
||||
expect(last).toBeTruthy()
|
||||
|
||||
// 드래그 시작/이동(왼쪽 +16, 위 +8 가정)
|
||||
last.onDragStart?.()
|
||||
last.onDrag?.({ left: 26, top: 18 }) // 기준 계산은 구현에 따름; 테스트는 두 객체가 동일 delta 이동했는지만 확인
|
||||
|
||||
const o1 = screen.getByTestId('obj-o1')
|
||||
const o2 = screen.getByTestId('obj-o2')
|
||||
|
||||
await waitFor(() => {
|
||||
// 두 객체가 같은 delta로 이동했다고 가정(정확 좌표는 스냅 규칙에 의해 결정되므로 상대 비교는 이후 강화 예정)
|
||||
expect(o1.style.left).not.toBe('10px')
|
||||
expect(o2.style.left).not.toBe('80px')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => {
|
||||
it('ArrowRight nudges and snaps x to nearest 8px grid', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ left: '104px' }) // 101 + 1 => 102 -> snap8 => 104
|
||||
})
|
||||
|
||||
it('ArrowUp nudges and snaps y to nearest 8px grid', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowUp' })
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96
|
||||
})
|
||||
|
||||
it('Shift+ArrowRight nudges by 1px without grid snap', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight', shiftKey: true })
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ left: '102px' }) // 101 + 1 => 102 (no snap)
|
||||
})
|
||||
|
||||
it('Shift+ArrowUp nudges by 1px without grid snap', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowUp', shiftKey: true })
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ top: '99px' }) // 100 - 1 => 99 (no snap)
|
||||
})
|
||||
|
||||
it('Alt+ArrowRight nudges by 16px and snaps to grid', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight', altKey: true })
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ left: '120px' }) // 101 + 16 = 117 -> snap8 => 120
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#111111',
|
||||
layers: [
|
||||
{
|
||||
id: 'base',
|
||||
name: 'Base',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'text', x: 40, y: 40, width: 80, height: 40, rotate: 0, zIndex: 0, props: { text: 'A', fontSize: 16, color: '#fff' } },
|
||||
{ id: 'o2', type: 'text', x: 200, y: 40, width: 80, height: 40, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#fff' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Canvas resize behavior (단일 선택 UX)', () => {
|
||||
it('단일 선택일 때 우하단 핸들 드래그로만 크기가 변하고, 다른 객체는 그대로 유지된다', () => {
|
||||
const frame = makeFrame()
|
||||
render(<Canvas frame={frame} />)
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
const obj1 = screen.getByTestId('obj-o1') as HTMLDivElement
|
||||
const obj2 = screen.getByTestId('obj-o2') as HTMLDivElement
|
||||
|
||||
// o1만 선택
|
||||
fireEvent.mouseDown(obj1, { clientX: 50, clientY: 50 })
|
||||
|
||||
const handle = screen.getByTestId('resize-handle-o1')
|
||||
// 우하단 방향으로 드래그 → width/height 증가
|
||||
fireEvent.mouseDown(handle, { clientX: 120, clientY: 80 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 180, clientY: 140 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const w1 = parseInt(obj1.style.width, 10)
|
||||
const h1 = parseInt(obj1.style.height, 10)
|
||||
expect(w1).toBeGreaterThan(80)
|
||||
expect(h1).toBeGreaterThan(40)
|
||||
|
||||
// o2는 변하지 않는다
|
||||
const w2 = parseInt(obj2.style.width, 10)
|
||||
const h2 = parseInt(obj2.style.height, 10)
|
||||
expect(w2).toBe(80)
|
||||
expect(h2).toBe(40)
|
||||
|
||||
// 반대 방향으로 드래그하면 줄어든다
|
||||
const handle2 = screen.getByTestId('resize-handle-o1')
|
||||
fireEvent.mouseDown(handle2, { clientX: 180, clientY: 140 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 130, clientY: 100 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
const w1b = parseInt(obj1.style.width, 10)
|
||||
const h1b = parseInt(obj1.style.height, 10)
|
||||
expect(w1b).toBeLessThan(w1)
|
||||
expect(h1b).toBeLessThan(h1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas resize with 8px snap', () => {
|
||||
it('dragging bottom-right handle resizes and snaps to nearest 8px', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('resize-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start resize
|
||||
fireEvent.mouseDown(handle, { clientX: 150, clientY: 140 })
|
||||
// move by +7,+9 → width 50-> 57 -> snap8 => 56, height 40->49 -> snap8 => 48
|
||||
fireEvent.mouseMove(canvas, { clientX: 157, clientY: 149 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ width: '56px', height: '48px' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1',
|
||||
width: 400,
|
||||
height: 300,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'L1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WYSIWYG Canvas rotate with snap', () => {
|
||||
it('rotates with 15deg snap by default', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('rotate-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
// start rotate at 0deg
|
||||
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
|
||||
// move +16px on x → +16deg → snap15 => 15deg
|
||||
fireEvent.mouseMove(canvas, { clientX: 116, clientY: 100 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ transform: 'rotate(15deg)' })
|
||||
})
|
||||
|
||||
it('rotates with 1deg step while holding Shift', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<Canvas frame={frame} />)
|
||||
const handle = getByTestId('rotate-handle-o1')
|
||||
const canvas = getByTestId('wysiwyg-canvas')
|
||||
|
||||
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
|
||||
// move +7px with Shift → +7deg, no snap
|
||||
fireEvent.mouseMove(canvas, { clientX: 107, clientY: 100, shiftKey: true })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
const obj = getByTestId('obj-o1')
|
||||
expect(obj).toHaveStyle({ transform: 'rotate(7deg)' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,808 @@
|
||||
"use client"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Moveable from 'react-moveable'
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function snap8(v: number) {
|
||||
return Math.round(v / 8) * 8
|
||||
}
|
||||
|
||||
// (removed unused type guard)
|
||||
|
||||
// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가
|
||||
type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null }
|
||||
if (typeof document !== 'undefined') {
|
||||
const d = document as DocWithEFP
|
||||
if (typeof d.elementFromPoint !== 'function') {
|
||||
d.elementFromPoint = (_x: number, _y: number) => { void _x; void _y; return null }
|
||||
}
|
||||
}
|
||||
|
||||
export type CanvasProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
onSelectIds?: (ids: string[]) => void
|
||||
enableGuides?: boolean
|
||||
}
|
||||
|
||||
export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: CanvasProps) {
|
||||
const [model, setModel] = useState<Frame>(frame)
|
||||
// 외부 프레임 변경(예: 빌더에서 PropsPanel 수정/객체 추가)이 들어오면 동기화
|
||||
useEffect(() => { setModel(frame) }, [frame])
|
||||
const initialId = model.layers[0]?.objects[0]?.id ?? null
|
||||
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 [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)
|
||||
const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const [marquee, setMarquee] = useState<{
|
||||
active: boolean
|
||||
x0: number
|
||||
y0: number
|
||||
x1: number
|
||||
y1: number
|
||||
}>({ active: false, x0: 0, y0: 0, x1: 0, y1: 0 })
|
||||
// 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환)
|
||||
const selectedElRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const selected = useMemo(() => {
|
||||
for (const layer of model.layers) {
|
||||
const obj = layer.objects.find((o) => o.id === selectedId)
|
||||
if (obj) return obj
|
||||
}
|
||||
return null
|
||||
}, [model, selectedId])
|
||||
|
||||
// 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 = (enableGuides && (showGuides || isDragging)) ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
|
||||
return `${base}${pos}${guide}`
|
||||
}, [selectedIds.length, showGuides, isDragging, enableGuides, guidePos.x, guidePos.y, selected])
|
||||
|
||||
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
|
||||
setModel((prev) => {
|
||||
const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) }
|
||||
onChange?.(next)
|
||||
return next
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
const updateObjects = useCallback((ids: string[], updater: (o: WObject) => WObject) => {
|
||||
setModel((prev) => {
|
||||
const idSet = new Set(ids)
|
||||
const next: Frame = {
|
||||
...prev,
|
||||
layers: prev.layers.map((ly) => ({
|
||||
...ly,
|
||||
objects: ly.objects.map((o) => (idSet.has(o.id) ? updater(o) : o)),
|
||||
})),
|
||||
}
|
||||
onChange?.(next)
|
||||
return next
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!selected) return
|
||||
let dx = 0, dy = 0
|
||||
let step = 1
|
||||
let useSnap = true
|
||||
// Shift: 미세 이동(1px, 스냅 없음), Alt: 가속 이동(16px, 스냅 적용)
|
||||
if (e.shiftKey) { step = 1; useSnap = false }
|
||||
else if (e.altKey) { step = 16; useSnap = true }
|
||||
|
||||
if (e.key === 'ArrowLeft') dx = -step
|
||||
else if (e.key === 'ArrowRight') dx = step
|
||||
else if (e.key === 'ArrowUp') dy = -step
|
||||
else if (e.key === 'ArrowDown') dy = step
|
||||
else return
|
||||
e.preventDefault()
|
||||
|
||||
// 기준 객체(선택 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) {
|
||||
setSelectedIds((prev) => {
|
||||
const exists = prev.includes(o.id)
|
||||
if (exists) return prev.filter((id) => id !== o.id)
|
||||
const next = [...prev, o.id]
|
||||
try { onSelectIds?.(next) } catch {}
|
||||
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]
|
||||
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, onSelectIds, enableGuides])
|
||||
|
||||
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
|
||||
// guides 비활성화 상태에서 남아있다면 즉시 숨김
|
||||
if (!enableGuides && showGuides) setShowGuides(false)
|
||||
// 마퀴 선택 처리 우선
|
||||
if (marquee.active) {
|
||||
const x1 = e.clientX
|
||||
const y1 = e.clientY
|
||||
setMarquee((m) => ({ ...m, x1, y1 }))
|
||||
// 교차하는 객체 선택 갱신
|
||||
const minX = Math.min(marquee.x0, x1)
|
||||
const minY = Math.min(marquee.y0, y1)
|
||||
const maxX = Math.max(marquee.x0, x1)
|
||||
const maxY = Math.max(marquee.y0, y1)
|
||||
const hits: string[] = []
|
||||
for (const ly of model.layers) {
|
||||
for (const o of ly.objects) {
|
||||
const ol = o.x
|
||||
const ot = o.y
|
||||
const or = o.x + o.width
|
||||
const ob = o.y + o.height
|
||||
const inter = !(or < minX || ob < minY || ol > maxX || ot > maxY)
|
||||
if (inter) hits.push(o.id)
|
||||
}
|
||||
}
|
||||
setSelectedIds(hits)
|
||||
try { onSelectIds?.(hits) } catch {}
|
||||
return
|
||||
}
|
||||
const rot = rotatingRef.current
|
||||
if (rot) {
|
||||
const dx = e.clientX - rot.startX
|
||||
let next = rot.origDeg + dx
|
||||
if (!e.shiftKey) {
|
||||
next = Math.round(next / 15) * 15
|
||||
}
|
||||
if (selectedIds.length > 1 && rotateGroupRef.current) {
|
||||
const { ids, orig } = rotateGroupRef.current
|
||||
const base = orig[rot.id]
|
||||
const dd = next - base.d
|
||||
updateObjects(ids, (o) => {
|
||||
const o0 = orig[o.id]
|
||||
if (!o0) return o
|
||||
return { ...o, rotate: o0.d + dd }
|
||||
})
|
||||
} else {
|
||||
updateObject(rot.id, (o) => ({ ...o, rotate: next }))
|
||||
}
|
||||
return
|
||||
}
|
||||
const r = resizingRef.current
|
||||
if (r) {
|
||||
const dx = e.clientX - r.startX
|
||||
const dy = e.clientY - r.startY
|
||||
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
|
||||
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, 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
|
||||
// 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, 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
|
||||
}, [marquee.active, showGuides])
|
||||
|
||||
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject, dir: 'w' | 'n' | 'e' | 's' | 'ne' | 'se' | 'nw' | 'sw') => {
|
||||
e.stopPropagation()
|
||||
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, { 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] = { x: ob.x, y: ob.y, w: ob.width, h: ob.height }
|
||||
}
|
||||
}
|
||||
resizeGroupRef.current = { ids, orig }
|
||||
}, [selectedIds, model.layers])
|
||||
|
||||
const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||
e.stopPropagation()
|
||||
rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate }
|
||||
const ids = Array.from(new Set([...selectedIds, o.id]))
|
||||
const orig: Record<string, { d: number }> = {}
|
||||
for (const ly of model.layers) {
|
||||
for (const ob of ly.objects) {
|
||||
if (ids.includes(ob.id)) orig[ob.id] = { d: ob.rotate }
|
||||
}
|
||||
}
|
||||
rotateGroupRef.current = { ids, orig }
|
||||
}, [selectedIds, model.layers])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="application"
|
||||
aria-label="WYSIWYG Canvas"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => {
|
||||
// 빈 영역(객체/핸들이 아닌 영역) 클릭 시 마퀴 시작 또는 선택 해제
|
||||
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 {}
|
||||
}
|
||||
}}
|
||||
onMouseMove={onMouseMoveCanvas}
|
||||
onMouseUp={onMouseUpCanvas}
|
||||
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
|
||||
data-testid="wysiwyg-canvas"
|
||||
>
|
||||
{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' }} />
|
||||
</>
|
||||
)}
|
||||
<div role="status" aria-live="polite" data-testid="aria-live" style={{ position: 'absolute', left: -9999, top: 'auto', width: 1, height: 1, overflow: 'hidden' }}>{ariaLiveText}</div>
|
||||
{model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
data-testid={`obj-${o.id}`}
|
||||
aria-label={`object ${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownObj(e, o)}
|
||||
ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined}
|
||||
style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none', border: '1px solid rgba(148,163,184,0.6)', background: 'rgba(15,23,42,0.8)', color: '#f9fafb', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 4, boxSizing: 'border-box' }}
|
||||
>
|
||||
{/* 실제 오브젝트 콘텐츠 렌더링 */}
|
||||
{o.type === 'text' && (
|
||||
<p style={{ margin: 0, fontSize: o.props.fontSize, color: o.props.color }}>{o.props.text}</p>
|
||||
)}
|
||||
{o.type === 'image' && (
|
||||
<img src={o.props.src} alt={o.props.alt || ''} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
{o.type === 'button' && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
border: 'none',
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
background: o.props.background ?? '#2563eb',
|
||||
color: o.props.color ?? '#ffffff',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{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, '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
|
||||
data-testid={`rotate-handle-${o.id}`}
|
||||
onMouseDown={(e) => onMouseDownRotate(e, o)}
|
||||
style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)))}
|
||||
{/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */}
|
||||
<Moveable
|
||||
target={selectedId ? `[data-testid="obj-${selectedId}"]` : undefined}
|
||||
draggable={selectedIds.length === 1}
|
||||
rotatable={selectedIds.length === 1}
|
||||
snappable
|
||||
snapGridWidth={8}
|
||||
snapGridHeight={8}
|
||||
onDragStart={() => {
|
||||
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
|
||||
// 다중 선택 시: 그룹 bbox 기준으로 스냅 계산
|
||||
let baseLeft = selected.x
|
||||
let baseTop = selected.y
|
||||
let rawLeft = rawLeftInput
|
||||
let rawTop = rawTopInput
|
||||
let groupLeft = selected.x
|
||||
let groupTop = selected.y
|
||||
let groupWidth = selected.width
|
||||
let groupHeight = selected.height
|
||||
if (selectedIds.length > 1) {
|
||||
let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity
|
||||
for (const ly of model.layers) {
|
||||
for (const o of ly.objects) {
|
||||
if (!selectedIds.includes(o.id)) continue
|
||||
minL = Math.min(minL, o.x)
|
||||
minT = Math.min(minT, o.y)
|
||||
maxR = Math.max(maxR, o.x + o.width)
|
||||
maxB = Math.max(maxB, o.y + o.height)
|
||||
}
|
||||
}
|
||||
groupLeft = minL
|
||||
groupTop = minT
|
||||
groupWidth = Math.max(0, maxR - minL)
|
||||
groupHeight = Math.max(0, maxB - minT)
|
||||
const dxProp = rawLeftInput - selected.x
|
||||
const dyProp = rawTopInput - selected.y
|
||||
baseLeft = groupLeft
|
||||
baseTop = groupTop
|
||||
rawLeft = groupLeft + dxProp
|
||||
rawTop = groupTop + dyProp
|
||||
}
|
||||
let nx = snap8(rawLeft)
|
||||
let ny = snap8(rawTop)
|
||||
|
||||
// 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px
|
||||
const threshold = 8
|
||||
let snappedCenterX: number | undefined
|
||||
let snappedCenterY: number | undefined
|
||||
let centerDistX = Number.POSITIVE_INFINITY
|
||||
let centerDistY = Number.POSITIVE_INFINITY
|
||||
// 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표
|
||||
let edgeNx: number | undefined
|
||||
let edgeNy: number | undefined
|
||||
let guideEdgeX: number | undefined
|
||||
let guideEdgeY: number | undefined
|
||||
let minDistX = Number.POSITIVE_INFINITY
|
||||
let minDistY = Number.POSITIVE_INFINITY
|
||||
for (const layer of model.layers) {
|
||||
for (const obj of layer.objects) {
|
||||
if (selectedIds.includes(obj.id)) continue
|
||||
const otherCenterX = obj.x + obj.width / 2
|
||||
const selCenterX = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width) / 2
|
||||
const dxCenter = Math.abs(selCenterX - otherCenterX)
|
||||
if (dxCenter <= threshold && dxCenter < centerDistX) {
|
||||
centerDistX = dxCenter
|
||||
snappedCenterX = otherCenterX
|
||||
}
|
||||
const otherCenterY2 = obj.y + obj.height / 2
|
||||
const selCenterY2 = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height) / 2
|
||||
const dyCenter = Math.abs(selCenterY2 - otherCenterY2)
|
||||
if (dyCenter <= threshold && dyCenter < centerDistY) {
|
||||
centerDistY = dyCenter
|
||||
snappedCenterY = otherCenterY2
|
||||
}
|
||||
|
||||
// 에지 스냅 후보 계산 (X축)
|
||||
const selLeft = rawLeft
|
||||
const selRight = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width)
|
||||
const otherLeft = obj.x
|
||||
const otherRight = obj.x + obj.width
|
||||
const xPairs: Array<{dist: number; nx: number; guideX: number}> = [
|
||||
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L
|
||||
{ dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R
|
||||
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherLeft }, // R-L
|
||||
{ dist: Math.abs(selRight - otherRight), nx: otherRight - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherRight }, // R-R
|
||||
]
|
||||
for (const p of xPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistX) {
|
||||
minDistX = p.dist
|
||||
edgeNx = p.nx
|
||||
guideEdgeX = p.guideX
|
||||
}
|
||||
}
|
||||
|
||||
// 에지 스냅 후보 계산 (Y축)
|
||||
const selTop = rawTop
|
||||
const selBottom = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height)
|
||||
const otherTop = obj.y
|
||||
const otherBottom = obj.y + obj.height
|
||||
const yPairs: Array<{dist: number; ny: number; guideY: number}> = [
|
||||
{ dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T
|
||||
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B
|
||||
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherTop }, // B-T
|
||||
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherBottom }, // B-B
|
||||
]
|
||||
for (const p of yPairs) {
|
||||
if (p.dist <= threshold && p.dist < minDistY) {
|
||||
minDistY = p.dist
|
||||
edgeNy = p.ny
|
||||
guideEdgeY = p.guideY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 우선순위: 에지 스냅 > 중심 스냅 > 그리드
|
||||
if (typeof edgeNx === 'number') {
|
||||
nx = edgeNx
|
||||
} else if (typeof snappedCenterX === 'number') {
|
||||
nx = snappedCenterX - (selectedIds.length > 1 ? groupWidth : selected.width) / 2
|
||||
}
|
||||
if (typeof edgeNy === 'number') {
|
||||
ny = edgeNy
|
||||
} else if (typeof snappedCenterY === 'number') {
|
||||
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
|
||||
}
|
||||
|
||||
// 가이드 좌표 업데이트(에지 가이드 우선, 없으면 중심, 최후에는 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
|
||||
const dy = ny - baseTop
|
||||
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + dx, y: o.y + dy }))
|
||||
} else {
|
||||
updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDragging(false)
|
||||
setShowGuides(false)
|
||||
}}
|
||||
onRotate={({ rotate, inputEvent }) => {
|
||||
if (!selected) return
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1', width: 400, height: 300, background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] },
|
||||
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('LayersPanel', () => {
|
||||
it('reorders layers with up/down buttons', () => {
|
||||
const frame = makeFrame()
|
||||
const onChange = vi.fn()
|
||||
const { getByTestId, getAllByTestId } = render(<LayersPanel frame={frame} onChange={onChange} />)
|
||||
const items = getAllByTestId('layer-item')
|
||||
expect(items[0]).toHaveTextContent('Base')
|
||||
expect(items[1]).toHaveTextContent('Text')
|
||||
|
||||
fireEvent.click(getByTestId('layer-down-l1'))
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
const next = (onChange.mock.lastCall?.[0]) as Frame
|
||||
expect(next.layers[0].id).toBe('l2')
|
||||
expect(next.layers[1].id).toBe('l1')
|
||||
})
|
||||
|
||||
it('toggles lock and visibility', () => {
|
||||
const frame = makeFrame()
|
||||
const { getByTestId } = render(<LayersPanel frame={frame} onChange={() => {}} />)
|
||||
fireEvent.click(getByTestId('layer-lock-l2'))
|
||||
fireEvent.click(getByTestId('layer-hide-l2'))
|
||||
// no crash; states toggled reflected in aria-pressed
|
||||
expect(getByTestId('layer-lock-l2')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(getByTestId('layer-hide-l2')).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type LayersPanelProps = {
|
||||
frame: Frame
|
||||
onChange?: (next: Frame) => void
|
||||
}
|
||||
|
||||
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
|
||||
const [model, setModel] = useState<Frame>(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
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= model.layers.length) return
|
||||
const nextLayers = model.layers.slice()
|
||||
const [it] = nextLayers.splice(idx, 1)
|
||||
nextLayers.splice(j, 0, it)
|
||||
const next = { ...model, layers: nextLayers }
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}, [model, onChange])
|
||||
|
||||
const toggle = useCallback((id: string, key: 'locked' | 'visible') => {
|
||||
const nextLayers = model.layers.map((l) => l.id === id ? { ...l, [key]: key === 'visible' ? !l.visible : !l.locked } : l)
|
||||
const next = { ...model, layers: nextLayers }
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}, [model, onChange])
|
||||
|
||||
return (
|
||||
<div aria-label="Layers Panel">
|
||||
{model.layers.map((l) => (
|
||||
<div key={l.id} data-testid="layer-item" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span>{l.name}</span>
|
||||
<button type="button" data-testid={`layer-up-${l.id}`} onClick={() => move(l.id, -1)} aria-label={`move up ${l.name}`}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" data-testid={`layer-down-${l.id}`} onClick={() => move(l.id, 1)} aria-label={`move down ${l.name}`}>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`layer-lock-${l.id}`}
|
||||
aria-pressed={l.locked ? 'true' : 'false'}
|
||||
onClick={() => toggle(l.id, 'locked')}
|
||||
aria-label={`lock ${l.name}`}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`layer-hide-${l.id}`}
|
||||
aria-pressed={l.visible ? 'false' : 'true'}
|
||||
onClick={() => toggle(l.id, 'visible')}
|
||||
aria-label={`toggle ${l.name} visibility`}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
|
||||
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
describe('PropsPanel', () => {
|
||||
it('edits position/size/rotate numeric fields', () => {
|
||||
const obj: WObject = { id: 'o1', type: 'image', x: 10, y: 20, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } }
|
||||
const calls: WObject[] = []
|
||||
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
|
||||
fireEvent.change(getByLabelText('X'), { target: { value: '12' } })
|
||||
fireEvent.change(getByLabelText('Y'), { target: { value: '30' } })
|
||||
fireEvent.change(getByLabelText('Width'), { target: { value: '120' } })
|
||||
fireEvent.change(getByLabelText('Height'), { target: { value: '88' } })
|
||||
fireEvent.change(getByLabelText('Rotate'), { target: { value: '15' } })
|
||||
const last = calls.at(-1)!
|
||||
expect(last.x).toBe(12)
|
||||
expect(last.y).toBe(30)
|
||||
expect(last.width).toBe(120)
|
||||
expect(last.height).toBe(88)
|
||||
expect(last.rotate).toBe(15)
|
||||
})
|
||||
|
||||
it('edits image src/alt', () => {
|
||||
const obj: WObject = { id: 'o2', type: 'image', x: 0, y: 0, width: 10, height: 10, rotate: 0, zIndex: 0, props: { src: 'x', alt: 'a' } }
|
||||
const calls: WObject[] = []
|
||||
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
|
||||
fireEvent.change(getByLabelText('Src'), { target: { value: 'https://example.com/img.jpg' } })
|
||||
fireEvent.change(getByLabelText('Alt'), { target: { value: 'Example' } })
|
||||
expect(calls.at(-1)?.props).toEqual({ src: 'https://example.com/img.jpg', alt: 'Example' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type PropsPanelProps = {
|
||||
object: WObject
|
||||
onChange?: (next: WObject) => void
|
||||
}
|
||||
|
||||
function num(v: string, fallback: number) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function isImage(o: WObject): o is Extract<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 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)
|
||||
|
||||
useEffect(() => {
|
||||
setModel(object)
|
||||
}, [object])
|
||||
|
||||
const emit = (next: WObject) => {
|
||||
setModel(next)
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const update = (patch: Partial<Pick<WObject, 'x' | 'y' | 'width' | 'height' | 'rotate'>>): void => {
|
||||
const next: WObject = { ...model, ...patch }
|
||||
emit(next)
|
||||
}
|
||||
const updateImageProps = (patch: Partial<{ src: string; alt?: string }>): void => {
|
||||
if (!isImage(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
const updateTextProps = (patch: Partial<{ text: string }>): void => {
|
||||
if (!isText(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
const updateButtonProps = (patch: Partial<{ label: string }>): void => {
|
||||
if (!isButton(model)) return
|
||||
const next: WObject = { ...model, props: { ...model.props, ...patch } }
|
||||
emit(next)
|
||||
}
|
||||
|
||||
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>
|
||||
<label>
|
||||
X
|
||||
<input aria-label="X" type="number" value={model.x} onChange={(e) => update({ x: num(e.target.value, model.x) })} />
|
||||
</label>
|
||||
<label>
|
||||
Y
|
||||
<input aria-label="Y" type="number" value={model.y} onChange={(e) => update({ y: num(e.target.value, model.y) })} />
|
||||
</label>
|
||||
<label>
|
||||
Width
|
||||
<input aria-label="Width" type="number" value={model.width} onChange={(e) => update({ width: num(e.target.value, model.width) })} />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input aria-label="Height" type="number" value={model.height} onChange={(e) => update({ height: num(e.target.value, model.height) })} />
|
||||
</label>
|
||||
<label>
|
||||
Rotate
|
||||
<input aria-label="Rotate" type="number" value={model.rotate} onChange={(e) => update({ rotate: num(e.target.value, model.rotate) })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isText(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Text
|
||||
<input aria-label="Text" type="text" value={model.props.text} onChange={(e) => updateTextProps({ text: e.target.value })} />
|
||||
</label>
|
||||
</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>
|
||||
Label
|
||||
<input aria-label="Label" type="text" value={model.props.label} onChange={(e) => updateButtonProps({ label: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage(model) && (
|
||||
<div>
|
||||
<label>
|
||||
Src
|
||||
<input aria-label="Src" type="text" value={model.props.src} onChange={(e) => updateImageProps({ src: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Alt
|
||||
<input aria-label="Alt" type="text" value={model.props.alt || ''} onChange={(e) => updateImageProps({ alt: e.target.value })} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
let spyCreate: ReturnType<typeof vi.spyOn> | null = null
|
||||
let spyRevoke: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
describe('WYSIWYG Builder acceptance', () => {
|
||||
beforeEach(() => {
|
||||
spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
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 }))
|
||||
const obj = await screen.findByTestId('obj-text-1')
|
||||
|
||||
// Select and edit via Properties panel
|
||||
fireEvent.mouseDown(obj)
|
||||
const inputX = await screen.findByLabelText('X')
|
||||
fireEvent.change(inputX, { target: { value: '80' } })
|
||||
|
||||
await waitFor(() => {
|
||||
// style left updated on canvas object (snap may apply, accept near 80)
|
||||
const left = parseInt((obj as HTMLElement).style.left || '0', 10)
|
||||
expect(Math.abs(left - 80)).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
// Export exists and triggers download
|
||||
const exportBtn = screen.getByRole('button', { name: /Export/i })
|
||||
fireEvent.click(exportBtn)
|
||||
// No throw means success; optionally wait a tick
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
it('uploads a local image and renders it via AssetManager path', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
const input = screen.getByTestId('wysiwyg-image-input') as HTMLInputElement
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'hero.png', { type: 'image/png' })
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
const img = await screen.findByRole('img')
|
||||
const src = (img as HTMLImageElement).src
|
||||
expect(src).toContain('/assets/')
|
||||
})
|
||||
|
||||
it('syncs width/height/rotate from Properties panel to Canvas for a text object', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Add Text and select
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj] = await screen.findAllByTestId(/obj-text-/)
|
||||
fireEvent.mouseDown(obj)
|
||||
|
||||
// Edit width/height/rotate via Properties
|
||||
const inputW = await screen.findByLabelText('Width')
|
||||
const inputH = await screen.findByLabelText('Height')
|
||||
const inputR = await screen.findByLabelText('Rotate')
|
||||
|
||||
fireEvent.change(inputW, { target: { value: '200' } })
|
||||
fireEvent.change(inputH, { target: { value: '60' } })
|
||||
fireEvent.change(inputR, { target: { value: '30' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const style = (obj as HTMLElement).style
|
||||
expect(style.width).toBe('200px')
|
||||
expect(style.height).toBe('60px')
|
||||
expect(style.transform).toContain('30deg')
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs text/button specific props from Properties to Canvas content', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// Text
|
||||
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')
|
||||
fireEvent.change(textInput, { target: { value: 'Hello World' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textObj).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
// Button
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Button/i }))
|
||||
const [buttonObj] = await screen.findAllByTestId(/obj-button-/)
|
||||
fireEvent.mouseDown(buttonObj)
|
||||
const labelInput = await screen.findByLabelText('Label')
|
||||
fireEvent.change(labelInput, { target: { value: 'CTA' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(buttonObj).toHaveTextContent('CTA')
|
||||
})
|
||||
})
|
||||
|
||||
it('supports Shift+click multiselect and group drag on the builder canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 두 텍스트 추가
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
|
||||
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
|
||||
|
||||
// 첫 번째 선택
|
||||
fireEvent.mouseDown(obj1, { clientX: 70, clientY: 70 })
|
||||
// Shift+클릭으로 두 번째 추가 선택
|
||||
fireEvent.mouseDown(obj2, { clientX: 80, clientY: 120, shiftKey: true })
|
||||
|
||||
// 멀티선택 상태에서 두 객체 모두 outline 스타일을 갖는지만 확인
|
||||
expect((obj1 as HTMLElement).style.outline).toContain('2px solid')
|
||||
expect((obj2 as HTMLElement).style.outline).toContain('2px solid')
|
||||
})
|
||||
|
||||
it('supports marquee selection on the builder canvas', async () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
|
||||
// 텍스트 두 개 배치
|
||||
const addTextBtn = screen.getByRole('button', { name: /^Add Text$/i })
|
||||
fireEvent.click(addTextBtn)
|
||||
fireEvent.click(addTextBtn)
|
||||
const [obj1, obj2] = await screen.findAllByTestId(/obj-text-/)
|
||||
|
||||
// 마퀴 드래그 박스로 두 객체 모두 포함되도록 드래그
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40 })
|
||||
fireEvent.mouseMove(canvas, { clientX: 300, clientY: 300 })
|
||||
fireEvent.mouseUp(canvas)
|
||||
|
||||
// 선택된 두 객체가 모두 outline 스타일을 갖는지 확인
|
||||
expect((obj1 as HTMLElement).style.outline).toContain('2px solid')
|
||||
expect((obj2 as HTMLElement).style.outline).toContain('2px solid')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WysiwygBuilderPage } from '@/components/wysiwyg/WysiwygBuilderPage'
|
||||
|
||||
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 }))
|
||||
|
||||
// 레이어 패널에서 visible 토글 버튼 상태만 검증
|
||||
const hideBtn = screen.getByTestId('layer-hide-base')
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(hideBtn)
|
||||
expect(hideBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
// 다시 클릭하면 보이도록 토글
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
let spyCreate: ReturnType<typeof vi.spyOn> | null = null
|
||||
let spyRevoke: ReturnType<typeof vi.spyOn> | null = null
|
||||
|
||||
describe('WysiwygBuilderPage', () => {
|
||||
beforeEach(() => {
|
||||
spyCreate = vi.spyOn(global.URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
spyRevoke = vi.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
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 })
|
||||
fireEvent.click(addText)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('obj-text-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('has an Export button', () => {
|
||||
render(<WysiwygBuilderPage />)
|
||||
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
|
||||
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
|
||||
function emptyFrame(): Frame {
|
||||
return {
|
||||
id: 'f-wysiwyg',
|
||||
width: 800,
|
||||
height: 500,
|
||||
background: '#111111',
|
||||
layers: [
|
||||
{ id: 'base', name: 'Base', visible: true, locked: false, objects: [] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function download(filename: string, data: Uint8Array) {
|
||||
const ab = data.slice().buffer as ArrayBuffer
|
||||
const blob = new Blob([ab], { type: 'application/zip' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
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) {
|
||||
const o = ly.objects.find((x) => x.id === selectedId)
|
||||
if (o) return o
|
||||
}
|
||||
return null
|
||||
}, [frame, selectedId])
|
||||
|
||||
const onChangeFrame = useCallback((next: Frame) => setFrame(next), [])
|
||||
const onSelectIds = useCallback((ids: string[]) => setSelectedIds(ids), [])
|
||||
|
||||
const addText = useCallback(() => {
|
||||
const id = `text-${textCounter++}`
|
||||
const obj: WObject = { id, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: 'Text', fontSize: 18, color: '#ffffff' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
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: '' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}, [])
|
||||
|
||||
const onUploadImage = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
const file = files[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (!(result instanceof ArrayBuffer)) return
|
||||
const bytes = new Uint8Array(result)
|
||||
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: 'grouped' })
|
||||
assetManagerRef.current = am
|
||||
am.add({ name: file.name, type: file.type, bytes }).then((ref) => {
|
||||
const id = `image-${imageCounter++}`
|
||||
const obj: WObject = {
|
||||
id,
|
||||
type: 'image',
|
||||
x: 60,
|
||||
y: 120,
|
||||
width: 120,
|
||||
height: 90,
|
||||
rotate: 0,
|
||||
zIndex: 0,
|
||||
props: { src: `/${ref.path}` as string, alt: file.name },
|
||||
}
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => (i === 0 ? { ...l, objects: [...l.objects, obj] } : l)),
|
||||
}))
|
||||
setSelectedIds([id])
|
||||
}).catch(() => {
|
||||
// ignore upload errors in UI for now
|
||||
})
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}, [])
|
||||
|
||||
const addButton = useCallback(() => {
|
||||
const id = `button-${buttonCounter++}`
|
||||
const obj: WObject = { id, type: 'button', x: 60, y: 240, width: 120, height: 36, rotate: 0, zIndex: 0, props: { label: 'Button', href: '#', color: '#ffffff', background: '#2563eb' } }
|
||||
setFrame((prev) => ({
|
||||
...prev,
|
||||
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
||||
}))
|
||||
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,
|
||||
layers: prev.layers.map((l) => ({
|
||||
...l,
|
||||
objects: l.objects.map((o) => (o.id === next.id ? (next as WObject) : o)),
|
||||
})),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const doExport = useCallback(async () => {
|
||||
const out = exportFrame(frame)
|
||||
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png','jpg','jpeg','webp','svg','woff2','txt'], pathStrategy: 'grouped' })
|
||||
assetManagerRef.current = am
|
||||
const extras: Record<string, Uint8Array> = {
|
||||
'assets.json': buildAssetsMetadata(am),
|
||||
'assets.index.json': buildAssetsIndex(am),
|
||||
'assets.integrity.index.json': buildAssetsIntegrityIndex(am),
|
||||
}
|
||||
const assets = am.toZipStructure()
|
||||
const zip = await buildZip({ html: out.html, css: out.css, js: out.js, assets, extras })
|
||||
download('landing.zip', zip)
|
||||
}, [frame])
|
||||
|
||||
return (
|
||||
<div className="p-4 grid grid-cols-[260px_1fr_320px] gap-4">
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
data-testid="wysiwyg-image-input"
|
||||
onChange={onUploadImage}
|
||||
/>
|
||||
</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">
|
||||
<LayersPanel frame={frame} onChange={setFrame} />
|
||||
</div>
|
||||
</div>
|
||||
<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 key={`guides-${guidesOn ? 'on' : 'off'}`} frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} enableGuides={guidesOn} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-2">
|
||||
<h2 className="text-sm font-semibold mb-2">Properties</h2>
|
||||
{selectedObject ? (
|
||||
<PropsPanel object={selectedObject} onChange={onChangeObject} />
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Select an object to edit</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WysiwygBuilderPage
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { WysiwygPage } from '@/components/wysiwyg/WysiwygPage'
|
||||
|
||||
describe('WysiwygPage', () => {
|
||||
it('renders Canvas with a sample frame and aria-live region', () => {
|
||||
render(<WysiwygPage />)
|
||||
const canvas = screen.getByTestId('wysiwyg-canvas')
|
||||
expect(canvas).toBeInTheDocument()
|
||||
// at least one object is present
|
||||
expect(screen.getByTestId('obj-o1')).toBeInTheDocument()
|
||||
// a11y live region exists
|
||||
expect(screen.getByTestId('aria-live')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function sampleFrame(): Frame {
|
||||
return {
|
||||
id: 'f-sample',
|
||||
width: 600,
|
||||
height: 400,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'Base',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'o1', type: 'image', x: 40, y: 40, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'https://via.placeholder.com/100x80', alt: 'A' } },
|
||||
{ id: 'o2', type: 'text', x: 200, y: 60, width: 200, height: 40, rotate: 0, zIndex: 1, props: { text: 'WYSIWYG', fontSize: 20, color: '#111111' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function WysiwygPage() {
|
||||
const frame = sampleFrame()
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-xl font-semibold">WYSIWYG Canvas</h1>
|
||||
<Canvas frame={frame} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WysiwygPage
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('AssetManager pathStrategy grouped', () => {
|
||||
it('writes assets under assets/images and assets/fonts when grouped', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: 'grouped' })
|
||||
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
|
||||
const font = await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
|
||||
expect(img.path).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/)
|
||||
expect(font.path).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/)
|
||||
const zip = am.toZipStructure()
|
||||
const keys = Object.keys(zip)
|
||||
expect(keys.some(k => /^assets\/images\//.test(k))).toBe(true)
|
||||
expect(keys.some(k => /^assets\/fonts\//.test(k))).toBe(true)
|
||||
})
|
||||
|
||||
it('rewriteUrl marks referenced originals and returns grouped path', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png'], pathStrategy: 'grouped' })
|
||||
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
|
||||
const rewritten = am.rewriteUrl('local:hero.png')
|
||||
expect(rewritten).toBe(img.path)
|
||||
expect(am.referencedList()).toContain('hero.png')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ export type AssetRef = {
|
||||
export type AssetManagerOptions = {
|
||||
maxBytes: number
|
||||
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
|
||||
pathStrategy?: 'flat' | 'grouped'
|
||||
}
|
||||
|
||||
function extOf(name: string): string {
|
||||
@@ -36,12 +37,15 @@ function hash8(bytes: Uint8Array): string {
|
||||
export class AssetManager {
|
||||
private readonly maxBytes: number
|
||||
private readonly allowed: Set<string>
|
||||
private readonly pathStrategy: 'flat' | 'grouped'
|
||||
private byHash = new Map<string, AssetRef>()
|
||||
private byOriginal = new Map<string, string>() // originalName -> path
|
||||
private referenced = new Set<string>() // originalName referenced via rewrite
|
||||
|
||||
constructor(opts: AssetManagerOptions) {
|
||||
this.maxBytes = opts.maxBytes
|
||||
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
||||
this.pathStrategy = opts.pathStrategy ?? 'flat'
|
||||
}
|
||||
|
||||
async add(file: AssetInput): Promise<AssetRef> {
|
||||
@@ -60,7 +64,7 @@ export class AssetManager {
|
||||
return existing
|
||||
}
|
||||
|
||||
const path = `assets/${h}.${ext}`
|
||||
const path = this.makePath(h, ext, file.type)
|
||||
const ref: AssetRef = {
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
@@ -91,8 +95,23 @@ export class AssetManager {
|
||||
if (urlOrLocalRef.startsWith('local:')) {
|
||||
const key = urlOrLocalRef.slice('local:'.length)
|
||||
const p = this.byOriginal.get(key)
|
||||
if (p) this.referenced.add(key)
|
||||
return p ?? urlOrLocalRef
|
||||
}
|
||||
return urlOrLocalRef
|
||||
}
|
||||
|
||||
referencedList(): string[] {
|
||||
return Array.from(this.referenced)
|
||||
}
|
||||
|
||||
private makePath(hash8: string, ext: string, mime: string): string {
|
||||
if (this.pathStrategy === 'grouped') {
|
||||
let group = 'other'
|
||||
if (mime.startsWith('image/')) group = 'images'
|
||||
else if (mime.startsWith('font/')) group = 'fonts'
|
||||
return `assets/${group}/${hash8}.${ext}`
|
||||
}
|
||||
return `assets/${hash8}.${ext}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
type IndexShape = { images: string[]; fonts: string[]; other: string[] }
|
||||
|
||||
describe('assets.index.json ↔ assets.json consistency', () => {
|
||||
it.each([
|
||||
{ mode: 'flat' as const },
|
||||
{ mode: 'grouped' as const },
|
||||
])('ensures index paths exist in assets.json zipPath with correct group ($mode)', async ({ mode }) => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt','jpg'], pathStrategy: mode })
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
|
||||
await am.add({ name: 'thumb.jpg', type: 'image/jpeg', bytes: u8(11) })
|
||||
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
|
||||
await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) })
|
||||
|
||||
const indexBytes = buildAssetsIndex(am)
|
||||
const metaBytes = buildAssetsMetadata(am)
|
||||
|
||||
const index = JSON.parse(new TextDecoder().decode(indexBytes)) as IndexShape
|
||||
const meta = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: Array<{ zipPath: string; group: 'images'|'fonts'|'other' }> }
|
||||
|
||||
const metaByPath = new Map(meta.assets.map(a => [a.zipPath, a.group]))
|
||||
|
||||
for (const p of index.images) {
|
||||
expect(metaByPath.has(p)).toBe(true)
|
||||
expect(metaByPath.get(p)).toBe('images')
|
||||
}
|
||||
for (const p of index.fonts) {
|
||||
expect(metaByPath.has(p)).toBe(true)
|
||||
expect(metaByPath.get(p)).toBe('fonts')
|
||||
}
|
||||
for (const p of index.other) {
|
||||
expect(metaByPath.has(p)).toBe(true)
|
||||
expect(metaByPath.get(p)).toBe('other')
|
||||
}
|
||||
|
||||
// Optional: ensure no extra paths exist in meta that are missing in index groups
|
||||
const indexSet = new Set([...index.images, ...index.fonts, ...index.other])
|
||||
for (const a of meta.assets) {
|
||||
expect(indexSet.has(a.zipPath)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('Assets index builder', () => {
|
||||
it('groups assets by mime into images/fonts/other and lists zipPath', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(4) })
|
||||
await am.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(6) })
|
||||
await am.add({ name: 'c.txt', type: 'text/plain', bytes: u8(2) })
|
||||
const bytes = buildAssetsIndex(am)
|
||||
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
|
||||
expect(obj.images.length).toBe(1)
|
||||
expect(obj.fonts.length).toBe(1)
|
||||
expect(obj.other.length).toBe(1)
|
||||
expect(obj.images[0]).toMatch(/^assets\/[0-9a-f]{8}\.png$/)
|
||||
expect(obj.fonts[0]).toMatch(/^assets\/[0-9a-f]{8}\.woff2$/)
|
||||
expect(obj.other[0]).toMatch(/^assets\/[0-9a-f]{8}\.txt$/)
|
||||
})
|
||||
|
||||
it('sorts each group array by path (ascending)', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt','woff2'] })
|
||||
// Add in shuffled order so that sort is required
|
||||
await am.add({ name: 'z.png', type: 'image/png', bytes: u8(3) })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'm.txt', type: 'text/plain', bytes: u8(2) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(4) })
|
||||
await am.add({ name: 'f.woff2', type: 'font/woff2', bytes: u8(6) })
|
||||
await am.add({ name: 'e.woff2', type: 'font/woff2', bytes: u8(7) })
|
||||
const bytes = buildAssetsIndex(am)
|
||||
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
|
||||
const imgs = [...obj.images]
|
||||
const fonts = [...obj.fonts]
|
||||
const others = [...obj.other]
|
||||
const sortedImgs = [...imgs].sort()
|
||||
const sortedFonts = [...fonts].sort()
|
||||
const sortedOthers = [...others].sort()
|
||||
expect(imgs).toEqual(sortedImgs)
|
||||
expect(fonts).toEqual(sortedFonts)
|
||||
expect(others).toEqual(sortedOthers)
|
||||
})
|
||||
|
||||
it('respects grouped pathStrategy and outputs grouped paths in index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: 'grouped' })
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
|
||||
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
|
||||
await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) })
|
||||
const bytes = buildAssetsIndex(am)
|
||||
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
|
||||
expect(obj.images[0]).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/)
|
||||
expect(obj.fonts[0]).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/)
|
||||
expect(obj.other[0]).toMatch(/^assets\/other\/[0-9a-f]{8}\.txt$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetsIndex = {
|
||||
images: string[]
|
||||
fonts: string[]
|
||||
other: string[]
|
||||
}
|
||||
|
||||
export function buildAssetsIndex(am: AssetManager): Uint8Array {
|
||||
// 그룹별 경로 수집
|
||||
const images: string[] = []
|
||||
const fonts: string[] = []
|
||||
const other: string[] = []
|
||||
for (const ref of am.list()) {
|
||||
if (ref.mimeType.startsWith('image/')) images.push(ref.path)
|
||||
else if (ref.mimeType.startsWith('font/')) fonts.push(ref.path)
|
||||
else other.push(ref.path)
|
||||
}
|
||||
// 경로 사전순 정렬로 일정한 출력 보장
|
||||
images.sort()
|
||||
fonts.sort()
|
||||
other.sort()
|
||||
const obj: AssetsIndex = { images, fonts, other }
|
||||
const json = JSON.stringify(obj, null, 2)
|
||||
return new TextEncoder().encode(json)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('assets.integrity.index.json', () => {
|
||||
it('emits path->integrity mapping consistent with assets.json and deterministically across modes and insertion order', async () => {
|
||||
for (const mode of ['flat','grouped'] as const) {
|
||||
// case1: insertion order A,B,C
|
||||
const am1 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode })
|
||||
await am1.add({ name: 'a.png', type: 'image/png', bytes: u8(3) })
|
||||
await am1.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) })
|
||||
await am1.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) })
|
||||
const idx1 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am1))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string }
|
||||
const meta1 = JSON.parse(new TextDecoder().decode(buildAssetsMetadata(am1))) as { assets: Array<{ zipPath: string; integrity: string }> }
|
||||
const metaMap1 = new Map(meta1.assets.map(a => [a.zipPath, a.integrity]))
|
||||
const paths1 = idx1.entries.map(e => e.path)
|
||||
const sorted1 = [...paths1].sort()
|
||||
expect(paths1).toEqual(sorted1)
|
||||
for (const e of idx1.entries) expect(metaMap1.get(e.path)).toBe(e.integrity)
|
||||
|
||||
// case2: insertion order shuffled C,A,B -> determinism of output
|
||||
const am2 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode })
|
||||
await am2.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) })
|
||||
await am2.add({ name: 'a.png', type: 'image/png', bytes: u8(3) })
|
||||
await am2.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) })
|
||||
const idx2 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am2))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string }
|
||||
// same entries set and order (sorted by path) -> equality
|
||||
expect(idx2.entries.map(e => e.path)).toEqual(idx1.entries.map(e => e.path))
|
||||
expect(idx2.entries.map(e => e.integrity)).toEqual(idx1.entries.map(e => e.integrity))
|
||||
// bundle hash depends on entries -> equal as well
|
||||
expect(idx2.bundleHash).toBe(idx1.bundleHash)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetsIntegrityIndex = {
|
||||
entries: Array<{ path: string; integrity: string; size: number }>
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
// Minimal synchronous SHA-256 implementation returning base64 string.
|
||||
function sha256Base64(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
])
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
])
|
||||
const l = bytes.length
|
||||
const bitLenHi = Math.floor((l >>> 29))
|
||||
const bitLenLo = (l << 3) >>> 0
|
||||
const withOne = l + 1
|
||||
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
|
||||
const totalLen = withOne + padLen + 8
|
||||
const m = new Uint8Array(totalLen)
|
||||
m.set(bytes)
|
||||
m[l] = 0x80
|
||||
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
|
||||
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
|
||||
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
|
||||
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
|
||||
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
|
||||
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
|
||||
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
|
||||
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
|
||||
const W = new Uint32Array(64)
|
||||
for (let i = 0; i < totalLen; i += 64) {
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const j = i + t * 4
|
||||
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
|
||||
}
|
||||
for (let t = 16; t < 64; t++) {
|
||||
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
|
||||
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
|
||||
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
H[0] = (H[0] + a) >>> 0
|
||||
H[1] = (H[1] + b) >>> 0
|
||||
H[2] = (H[2] + c) >>> 0
|
||||
H[3] = (H[3] + d) >>> 0
|
||||
H[4] = (H[4] + e) >>> 0
|
||||
H[5] = (H[5] + f) >>> 0
|
||||
H[6] = (H[6] + g) >>> 0
|
||||
H[7] = (H[7] + h) >>> 0
|
||||
}
|
||||
const out = new Uint8Array(32)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64')
|
||||
let s = ''; for (let i=0;i<out.length;i++) s += String.fromCharCode(out[i])
|
||||
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => string }).btoa
|
||||
if (!btoaFn) throw new Error('btoa not available')
|
||||
return btoaFn(s)
|
||||
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
|
||||
}
|
||||
|
||||
export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array {
|
||||
// 1) Collect entries: path + sha256 integrity
|
||||
const entries = am.list().map(ref => ({
|
||||
path: ref.path,
|
||||
integrity: `sha256-${sha256Base64(ref.bytes)}`,
|
||||
size: ref.bytes.length,
|
||||
}))
|
||||
// 2) Sort entries by path for deterministic output
|
||||
entries.sort((a, b) => a.path.localeCompare(b.path))
|
||||
// 3) Compute bundleHash over sorted lines: path + integrity joined by \n
|
||||
const lines: string[] = []
|
||||
for (const e of entries) { lines.push(e.path); lines.push(e.integrity) }
|
||||
const joined = new TextEncoder().encode(lines.join('\n'))
|
||||
const bundleHash = `sha256-${sha256Base64(joined)}`
|
||||
const obj: AssetsIntegrityIndex = { entries, bundleHash }
|
||||
const json = JSON.stringify(obj, null, 2)
|
||||
return new TextEncoder().encode(json)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
function u8pattern(n: number): Uint8Array {
|
||||
const arr = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) arr[i] = (i * 131 + 7) & 0xff
|
||||
return arr
|
||||
}
|
||||
|
||||
function sha256B64(bytes: Uint8Array): string {
|
||||
const h = crypto.createHash('sha256')
|
||||
h.update(Buffer.from(bytes))
|
||||
return h.digest('base64')
|
||||
}
|
||||
|
||||
describe('assets.json integrity = real SHA-256(base64)', () => {
|
||||
it('integrity equals sha256-<base64(sha256(bytes))>', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
|
||||
const bytes = u8pattern(64)
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes })
|
||||
const out = buildAssetsMetadata(am)
|
||||
const json = new TextDecoder().decode(out)
|
||||
const obj = JSON.parse(json) as { assets: AssetMetaItem[] }
|
||||
expect(obj.assets.length).toBe(1)
|
||||
const a = obj.assets[0]
|
||||
const expected = 'sha256-' + sha256B64(bytes)
|
||||
expect(a.integrity).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
||||
import type { AssetMetaItem } from '@/lib/exporter/assets.meta'
|
||||
|
||||
function u8(n: number): Uint8Array {
|
||||
const arr = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) arr[i] = (i * 31) & 0xff
|
||||
return arr
|
||||
}
|
||||
|
||||
describe('Assets metadata builder', () => {
|
||||
it('emits assets.json with originalName/mime/size/hash/ext/zipPath', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
|
||||
const bytes = u8(10)
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes })
|
||||
const metaBytes = buildAssetsMetadata(am)
|
||||
const json = new TextDecoder().decode(metaBytes)
|
||||
const obj = JSON.parse(json) as { assets: AssetMetaItem[] }
|
||||
expect(Array.isArray(obj.assets)).toBe(true)
|
||||
expect(obj.assets.length).toBe(1)
|
||||
const a = obj.assets[0]
|
||||
expect(a.originalName).toBe('hero.png')
|
||||
expect(a.mimeType).toBe('image/png')
|
||||
expect(a.size).toBe(bytes.byteLength)
|
||||
expect(a.ext).toBe('png')
|
||||
expect(a.hash8).toMatch(/^[0-9a-f]{8}$/)
|
||||
expect(a.zipPath).toMatch(/^assets\/[0-9a-f]{8}\.png$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetMetaItem = {
|
||||
originalName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
hash8: string
|
||||
ext: string
|
||||
zipPath: string
|
||||
integrity: string
|
||||
group: 'images' | 'fonts' | 'other'
|
||||
referencedAt: string[]
|
||||
}
|
||||
|
||||
// Minimal synchronous SHA-256 implementation returning base64 string.
|
||||
// Derived from the SHA-256 specification. Suitable for small payloads (asset bytes).
|
||||
function sha256Base64(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
])
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
])
|
||||
const l = bytes.length
|
||||
const bitLenHi = Math.floor((l >>> 29))
|
||||
const bitLenLo = (l << 3) >>> 0
|
||||
// Pre-processing (padding)
|
||||
const withOne = l + 1
|
||||
// pad to congruent 56 mod 64
|
||||
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
|
||||
const totalLen = withOne + padLen + 8
|
||||
const m = new Uint8Array(totalLen)
|
||||
m.set(bytes)
|
||||
m[l] = 0x80
|
||||
// append 64-bit big-endian length
|
||||
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
|
||||
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
|
||||
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
|
||||
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
|
||||
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
|
||||
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
|
||||
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
|
||||
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
|
||||
const W = new Uint32Array(64)
|
||||
for (let i = 0; i < totalLen; i += 64) {
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const j = i + t * 4
|
||||
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
|
||||
}
|
||||
for (let t = 16; t < 64; t++) {
|
||||
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
|
||||
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
|
||||
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
H[0] = (H[0] + a) >>> 0
|
||||
H[1] = (H[1] + b) >>> 0
|
||||
H[2] = (H[2] + c) >>> 0
|
||||
H[3] = (H[3] + d) >>> 0
|
||||
H[4] = (H[4] + e) >>> 0
|
||||
H[5] = (H[5] + f) >>> 0
|
||||
H[6] = (H[6] + g) >>> 0
|
||||
H[7] = (H[7] + h) >>> 0
|
||||
}
|
||||
const out = new Uint8Array(32)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i * 4 + 0] = (H[i] >>> 24) & 0xff
|
||||
out[i * 4 + 1] = (H[i] >>> 16) & 0xff
|
||||
out[i * 4 + 2] = (H[i] >>> 8) & 0xff
|
||||
out[i * 4 + 3] = (H[i] >>> 0) & 0xff
|
||||
}
|
||||
// base64 encode
|
||||
let b64 = ''
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
b64 = Buffer.from(out).toString('base64')
|
||||
} else {
|
||||
let s = ''
|
||||
for (let i = 0; i < out.length; i++) s += String.fromCharCode(out[i])
|
||||
// btoa is available in browsers
|
||||
b64 = btoa(s)
|
||||
}
|
||||
return b64
|
||||
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
|
||||
}
|
||||
|
||||
export function buildAssetsMetadata(am: AssetManager): Uint8Array {
|
||||
const list = am.list()
|
||||
const referenced = new Set(am.referencedList())
|
||||
const items: AssetMetaItem[] = list.map((ref) => {
|
||||
// Compute real SHA-256(base64) integrity synchronously for browser/Node
|
||||
const b64 = sha256Base64(ref.bytes)
|
||||
const group: 'images' | 'fonts' | 'other' = ref.mimeType.startsWith('image/')
|
||||
? 'images'
|
||||
: ref.mimeType.startsWith('font/')
|
||||
? 'fonts'
|
||||
: 'other'
|
||||
const referencedAt = referenced.has(ref.originalName) ? [`local:${ref.originalName}`] : []
|
||||
return {
|
||||
originalName: ref.originalName,
|
||||
mimeType: ref.mimeType,
|
||||
size: ref.bytes.byteLength,
|
||||
hash8: ref.hash8,
|
||||
ext: ref.ext,
|
||||
zipPath: ref.path,
|
||||
integrity: `sha256-${b64}`,
|
||||
group,
|
||||
referencedAt,
|
||||
}
|
||||
})
|
||||
const json = JSON.stringify({ assets: items }, null, 2)
|
||||
return new TextEncoder().encode(json)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Path Strategy',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('자산 경로 구조 옵션', () => {
|
||||
it('grouped 전략일 때 이미지가 assets/images/ 경로로 렌더된다', async () => {
|
||||
const page = makePage()
|
||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'], pathStrategy: 'grouped' })
|
||||
// add hero image asset
|
||||
const bytes = new Uint8Array([1,2,3,4,5])
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes })
|
||||
const out = exportPage(page, { assetManager: am })
|
||||
expect(out.html).toMatch(/assets\/images\/[a-f0-9]{8}\.png/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('referencedAt matrix across modes and multiple assets', () => {
|
||||
it.each([
|
||||
{ mode: 'flat' as const },
|
||||
{ mode: 'grouped' as const },
|
||||
])('tracks only referenced originals once per asset ($mode)', async ({ mode }) => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: mode })
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
|
||||
await am.add({ name: 'logo.png', type: 'image/png', bytes: u8(12) })
|
||||
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(14) })
|
||||
|
||||
// reference some assets multiple times; leave logo.png unreferenced
|
||||
am.rewriteUrl('local:hero.png')
|
||||
am.rewriteUrl('local:hero.png')
|
||||
am.rewriteUrl('local:main.woff2')
|
||||
|
||||
const metaBytes = buildAssetsMetadata(am)
|
||||
const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] }
|
||||
const byName = new Map(obj.assets.map(a => [a.originalName, a]))
|
||||
|
||||
const hero = byName.get('hero.png')!
|
||||
const logo = byName.get('logo.png')!
|
||||
const font = byName.get('main.woff2')!
|
||||
|
||||
expect(Array.isArray(hero.referencedAt)).toBe(true)
|
||||
expect(hero.referencedAt).toContain('local:hero.png')
|
||||
expect(hero.referencedAt.length).toBe(1)
|
||||
|
||||
expect(Array.isArray(font.referencedAt)).toBe(true)
|
||||
expect(font.referencedAt).toContain('local:main.woff2')
|
||||
expect(font.referencedAt.length).toBe(1)
|
||||
|
||||
expect(Array.isArray(logo.referencedAt)).toBe(true)
|
||||
expect(logo.referencedAt.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
|
||||
|
||||
function u8(n: number): Uint8Array {
|
||||
const arr = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) arr[i] = (i * 17) & 0xff
|
||||
return arr
|
||||
}
|
||||
|
||||
describe('referencedAt is populated when rewriteUrl is used', () => {
|
||||
it('marks originalName as referenced via local:<name>', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
|
||||
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(16) })
|
||||
// use rewriteUrl to simulate page referencing the local asset
|
||||
const rewritten = am.rewriteUrl('local:hero.png')
|
||||
expect(rewritten).toMatch(/assets\//)
|
||||
const metaBytes = buildAssetsMetadata(am)
|
||||
const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] }
|
||||
expect(obj.assets.length).toBe(1)
|
||||
const a = obj.assets[0]
|
||||
expect(Array.isArray(a.referencedAt)).toBe(true)
|
||||
expect(a.referencedAt).toContain('local:hero.png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'My Product',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: '', imageUrl: 'https://example.com/hero.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
function toStr(bytes: Uint8Array) {
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('extras 자동 보강', () => {
|
||||
it('manifest: 잘못된 theme_color는 무시하고 기본(primaryColor) 사용', () => {
|
||||
const page = makePage()
|
||||
const out = buildExtrasForPage(page, { manifest: { theme_color: 'not-a-hex' } })
|
||||
const manifest = JSON.parse(toStr(out['site.webmanifest']))
|
||||
expect(manifest.theme_color).toBe('#0ea5e9')
|
||||
})
|
||||
|
||||
it('manifest: short_name은 12자 이내로 자동 절단', () => {
|
||||
const page = makePage()
|
||||
const out = buildExtrasForPage(page, { manifest: { name: 'Super Long Product Name' } })
|
||||
const manifest = JSON.parse(toStr(out['site.webmanifest']))
|
||||
expect(manifest.short_name.length).toBeLessThanOrEqual(12)
|
||||
})
|
||||
|
||||
it('robots: 주어진 plain 경로 목록을 자동으로 User-agent 헤더와 Disallow 라인으로 보강', () => {
|
||||
const page = makePage()
|
||||
const robotsPlain = "/admin\n/private"
|
||||
const out = buildExtrasForPage(page, { robotsText: robotsPlain })
|
||||
const robots = toStr(out['robots.txt'])
|
||||
expect(robots).toMatch(/^User-agent: \*/)
|
||||
expect(robots).toMatch(/Disallow: \/admin/)
|
||||
expect(robots).toMatch(/Disallow: \/private/)
|
||||
})
|
||||
})
|
||||
+39
-8
@@ -9,6 +9,7 @@ export type ExtrasOverrides = {
|
||||
display: string
|
||||
theme_color: string
|
||||
}>
|
||||
sheetsMode?: 'auto' | 'include' | 'exclude'
|
||||
}
|
||||
|
||||
function toBytes(s: string): Uint8Array {
|
||||
@@ -16,7 +17,14 @@ function toBytes(s: string): Uint8Array {
|
||||
}
|
||||
|
||||
function buildRobots(base?: string): string {
|
||||
if (base && base.trim().length > 0) return base
|
||||
if (base && base.trim().length > 0) {
|
||||
const s = base.trim()
|
||||
if (/^User-agent:/i.test(s)) return s
|
||||
const lines = s.split(/\n+/).map((l) => l.trim()).filter((l) => l.length > 0)
|
||||
if (lines.length === 0) return 'User-agent: *\n'
|
||||
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
|
||||
return `User-agent: *\n${disallowLines.join('\n')}`
|
||||
}
|
||||
return 'User-agent: *\n'
|
||||
}
|
||||
|
||||
@@ -36,26 +44,49 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec
|
||||
const robotsText = buildRobots(overrides?.robotsText)
|
||||
out['robots.txt'] = toBytes(robotsText)
|
||||
|
||||
// site.webmanifest
|
||||
// site.webmanifest (auto-enrich and safe-merge)
|
||||
const manifestBase = deriveManifest(page)
|
||||
const manifestMerged = { ...manifestBase, ...(overrides?.manifest || {}) }
|
||||
const ov = overrides?.manifest || {}
|
||||
const name = ov.name && ov.name.trim().length > 0 ? ov.name : manifestBase.name
|
||||
const short_name = ov.short_name && ov.short_name.trim().length > 0
|
||||
? ov.short_name
|
||||
: (name.length > 12 ? name.slice(0, 12) : name)
|
||||
const start_url = ov.start_url && ov.start_url.trim().length > 0 ? ov.start_url : manifestBase.start_url
|
||||
const display = ov.display && ov.display.trim().length > 0 ? ov.display : manifestBase.display
|
||||
const hexOk = ov.theme_color ? /^#([0-9a-fA-F]{3}){1,2}$/.test(ov.theme_color) : false
|
||||
const theme_color = hexOk ? (ov.theme_color as string) : manifestBase.theme_color
|
||||
const manifestMerged = { name, short_name, start_url, display, theme_color }
|
||||
const manifestJson = JSON.stringify(manifestMerged, null, 2)
|
||||
out['site.webmanifest'] = toBytes(manifestJson)
|
||||
|
||||
// Google Sheets 템플릿: actionUrl 미지정 시 포함
|
||||
// Google Sheets 템플릿: 기본 auto. overrides.sheetsMode로 강제 포함/제외 가능
|
||||
const mode = overrides?.sheetsMode ?? 'auto'
|
||||
const action = page.form?.actionUrl?.trim()
|
||||
const hasAction = !!(action && action.length > 0)
|
||||
if (!hasAction) {
|
||||
const shouldInclude = mode === 'include' || (mode === 'auto' && !hasAction)
|
||||
if (shouldInclude) {
|
||||
const code = `/**
|
||||
* Google Apps Script Web App for form submissions
|
||||
* - Deploy as Web App and set URL as form action if needed.
|
||||
* @tool: landing-builder
|
||||
* @version: 1.0.0
|
||||
*/
|
||||
function doPost(e){
|
||||
// TODO: handle submission to Google Sheets
|
||||
return ContentService.createTextOutput('OK')
|
||||
try {
|
||||
// TODO: map incoming fields to sheet columns
|
||||
// Example:
|
||||
// const payload = JSON.parse(e.postData.contents || '{}');
|
||||
// const row = [payload.name, payload.email, new Date()];
|
||||
// SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Sheet1').appendRow(row);
|
||||
return ContentService.createTextOutput('OK')
|
||||
} catch (e) {
|
||||
return ContentService
|
||||
.createTextOutput('ERROR: ' + (e && (e as any).message ? (e as any).message : 'unknown'))
|
||||
.setMimeType(ContentService.MimeType.TEXT)
|
||||
}
|
||||
}
|
||||
`
|
||||
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.`
|
||||
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) Deploy as Web App로 배포 후 URL을 복사해 사용하세요.\n3) Map fields to columns: 제출 payload의 필드를 시트 컬럼에 매핑하세요.`
|
||||
out['sheets/Code.gs'] = toBytes(code)
|
||||
out['sheets/README.txt'] = toBytes(readme)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(fontFamily = 'Inter'): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Font Preload',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('폰트 preload 옵션', () => {
|
||||
it('옵션이 true이면 Google Fonts stylesheet preload/link를 head에 포함한다', () => {
|
||||
const page = makePage('Inter')
|
||||
const opts: ExportOptions = { fontPreload: true }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).toContain('rel="preload"')
|
||||
expect(out.html).toContain('as="style"')
|
||||
expect(out.html).toContain('fonts.googleapis.com/css2')
|
||||
expect(out.html).toContain('family=Inter')
|
||||
// onload print hack 포함
|
||||
expect(out.html).toMatch(/rel=\"stylesheet\"[^>]+media=\"print\"[^>]+onload=\"this.media='all'\"/)
|
||||
})
|
||||
|
||||
it('옵션이 false이면 preload/link를 포함하지 않는다', () => {
|
||||
const page = makePage('Inter')
|
||||
const opts: ExportOptions = { fontPreload: false }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).not.toMatch(/fonts.googleapis.com\/css2/)
|
||||
})
|
||||
})
|
||||
+42
-6
@@ -2,7 +2,19 @@ import type { Page, FormField } from '@/lib/schema/page'
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type Exported = { html: string; css: string; js: string }
|
||||
export type ExportOptions = { assetManager?: AssetManager }
|
||||
export type ExportOptions = {
|
||||
assetManager?: AssetManager
|
||||
fontPreconnect?: boolean
|
||||
fontPreload?: boolean
|
||||
robotsMeta?: string
|
||||
imageLoading?: 'lazy' | 'eager'
|
||||
imageDecoding?: 'async' | 'sync'
|
||||
imageFetchPriority?: 'high' | 'low'
|
||||
imageSrcset?: string
|
||||
imageSizes?: string
|
||||
imageAutoSizes?: boolean
|
||||
imageSizesPreset?: 'mobile-first' | 'desktop-fixed'
|
||||
}
|
||||
|
||||
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||
const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
||||
@@ -116,6 +128,19 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
const am = opts?.assetManager
|
||||
const imageUrlRaw: string = section.props.imageUrl
|
||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||
const loading = opts?.imageLoading ?? 'lazy'
|
||||
const decoding = opts?.imageDecoding ?? 'async'
|
||||
const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : ''
|
||||
const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : ''
|
||||
const defaultSizes = '(max-width: 640px) 100vw, 600px'
|
||||
const presetSizes = (() => {
|
||||
if (!opts?.imageSizesPreset) return ''
|
||||
if (opts.imageSizesPreset === 'mobile-first') return '(max-width: 640px) 100vw, 800px'
|
||||
if (opts.imageSizesPreset === 'desktop-fixed') return '1200px'
|
||||
return ''
|
||||
})()
|
||||
const sizesValue = opts?.imageSizes ?? (presetSizes || (opts?.imageSrcset && opts?.imageAutoSizes ? defaultSizes : ''))
|
||||
const sizes = sizesValue ? ` sizes="${escapeHtml(sizesValue)}"` : ''
|
||||
return `
|
||||
<section class="hero" aria-labelledby="hero-heading">
|
||||
<div class="container">
|
||||
@@ -124,7 +149,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||
</div>
|
||||
<div class="media">
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}"${fetchpriority}${srcset}${sizes} width="1200" height="675" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -222,7 +247,7 @@ function renderForm(page: Page) {
|
||||
`
|
||||
}
|
||||
|
||||
function buildHead(page: Page) {
|
||||
function buildHead(page: Page, opts?: ExportOptions) {
|
||||
const ogImage = page.seo.ogImage
|
||||
const analyticsParts: string[] = []
|
||||
const ga = page.analytics?.ga4MeasurementId
|
||||
@@ -250,11 +275,23 @@ function buildHead(page: Page) {
|
||||
</script>`)
|
||||
}
|
||||
const custom = page.analytics?.customHeadHtml || ''
|
||||
const preconnect = opts?.fontPreconnect !== false
|
||||
const robotsMeta = (opts?.robotsMeta && opts.robotsMeta.trim().length > 0) ? `<meta name="robots" content="${escapeHtml(opts!.robotsMeta!)}" />` : ''
|
||||
const fontPreload = opts?.fontPreload === true
|
||||
const rawFamily = (page.theme?.fontFamily || '').trim()
|
||||
const fontHref = rawFamily.length > 0 ? `https://fonts.googleapis.com/css2?family=${encodeURIComponent(rawFamily).replace(/%20/g, '+')}&display=swap` : ''
|
||||
return `
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="canonical" href="/" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
${preconnect ? '<link rel="preconnect" href="https://fonts.googleapis.com" />' : ''}
|
||||
${preconnect ? '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />' : ''}
|
||||
${fontPreload && fontHref ? `<link rel="preload" as="style" href="${fontHref}" />` : ''}
|
||||
${fontPreload && fontHref ? `<link rel="stylesheet" href="${fontHref}" media="print" onload="this.media='all'" />` : ''}
|
||||
${fontPreload && fontHref ? `<noscript><link rel="stylesheet" href="${fontHref}" /></noscript>` : ''}
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
||||
${robotsMeta}
|
||||
<title>${escapeHtml(page.seo.title)}</title>
|
||||
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||
@@ -336,8 +373,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(page.locale)}">
|
||||
<head>
|
||||
${buildHead(page)}
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
${buildHead(page, opts)}
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to content</a>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Img Perf',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('이미지 loading/decoding 토글', () => {
|
||||
it('기본은 loading="lazy", decoding="async"', () => {
|
||||
const page = makePage()
|
||||
const out = exportPage(page)
|
||||
expect(out.html).toContain('loading="lazy"')
|
||||
expect(out.html).toContain('decoding="async"')
|
||||
})
|
||||
|
||||
it('옵션으로 eager/sync로 바꿀 수 있다', () => {
|
||||
const page = makePage()
|
||||
const opts: ExportOptions = { imageLoading: 'eager', imageDecoding: 'sync' }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).toContain('loading="eager"')
|
||||
expect(out.html).toContain('decoding="sync"')
|
||||
})
|
||||
})
|
||||
@@ -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,87 @@
|
||||
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 Test',
|
||||
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/export options', () => {
|
||||
it('applies fetchpriority when provided', () => {
|
||||
const options: ExportOptions = { imageLoading: 'eager', imageDecoding: 'async', imageFetchPriority: 'high' }
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*fetchpriority="high"/)
|
||||
})
|
||||
|
||||
it('applies srcset and sizes when provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizes: '(max-width: 640px) 100vw, 600px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('applies default sizes when srcset is provided and imageAutoSizes is true', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('does not include sizes when srcset is provided but imageAutoSizes is false and sizes not provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: false,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).not.toMatch(/<img[^>]*sizes="/)
|
||||
})
|
||||
|
||||
it('applies preset sizes when imageSizesPreset is set (mobile-first)', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'mobile-first',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 800px"/)
|
||||
})
|
||||
|
||||
it('applies desktop-fixed preset when selected', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="1200px"/)
|
||||
})
|
||||
|
||||
it('explicit imageSizes overrides presets and auto', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
imageSizes: '(max-width: 768px) 100vw, 700px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 768px\) 100vw, 700px"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Head Links',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - head 링크 토큰', () => {
|
||||
it('site.webmanifest 링크를 포함한다', () => {
|
||||
const out = exportPage(makePage())
|
||||
expect(out.html).toMatch(/<link rel="manifest" href="\/site\.webmanifest"\s*\/>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Meta Robots',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('meta robots', () => {
|
||||
it('기본적으로 head에 robots 메타가 없다', () => {
|
||||
const page = makePage()
|
||||
const out = exportPage(page)
|
||||
expect(out.html).not.toContain('<meta name="robots"')
|
||||
})
|
||||
|
||||
it('옵션으로 robots 메타를 삽입할 수 있다', () => {
|
||||
const page = makePage()
|
||||
const opts: ExportOptions = { robotsMeta: 'noindex,nofollow' }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).toContain('<meta name="robots" content="noindex,nofollow" />')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'SEO/Perf 2',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img.example/hero.png' } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO2', description: 'Desc2' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - theme-color meta and hero image intrinsic size', () => {
|
||||
it('includes theme-color meta using page.theme.primaryColor', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<meta name="theme-color" content="#0ea5e9"\s*\/>/)
|
||||
})
|
||||
|
||||
it('renders hero image with width/height attributes for CLS reduction', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<img[^>]*\swidth="[0-9]+"/)
|
||||
expect(html).toMatch(/<img[^>]*\sheight="[0-9]+"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Test',
|
||||
seo: { title: 'T', description: 'D' },
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
sections: [],
|
||||
form: {
|
||||
fields: [],
|
||||
actionUrl: '',
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
locale: 'en',
|
||||
}
|
||||
|
||||
describe('Sheets template enhance', () => {
|
||||
it('includes version/meta and mapping/error guide in Code.gs and README', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'include' })
|
||||
const code = td(extras['sheets/Code.gs'])
|
||||
const readme = td(extras['sheets/README.txt'])
|
||||
|
||||
// version/meta header
|
||||
expect(code).toMatch(/@version:\s*\d+\.\d+\.\d+/)
|
||||
expect(code).toMatch(/@tool:\s*landing-builder/i)
|
||||
|
||||
// field mapping and error handling guides
|
||||
expect(code).toMatch(/TODO:\s*map incoming fields to sheet columns/i)
|
||||
expect(code).toMatch(/try\s*\{/)
|
||||
expect(code).toMatch(/catch\s*\(e\)/)
|
||||
|
||||
// README includes setup and mapping hint
|
||||
expect(readme).toMatch(/Apps Script/i)
|
||||
expect(readme).toMatch(/Deploy as Web App/i)
|
||||
expect(readme).toMatch(/Map fields to columns/i)
|
||||
})
|
||||
|
||||
it('respects exclude mode', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'exclude' })
|
||||
expect(Object.keys(extras).some(k => k.startsWith('sheets/'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
|
||||
function makePageNoAction(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Sheets Tpl',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [ { type: 'text', name: 'email', label: 'Email', required: true } ], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
function makePageWithAction(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Sheets Off',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: 'https://example.com/submit', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Sheets 템플릿 Export', () => {
|
||||
it('actionUrl 미지정 시 extras에 Sheets 템플릿 파일을 포함한다', async () => {
|
||||
const page = makePageNoAction()
|
||||
const extras = buildExtrasForPage(page)
|
||||
expect(Object.keys(extras)).toContain('sheets/Code.gs')
|
||||
expect(Object.keys(extras)).toContain('sheets/README.txt')
|
||||
|
||||
const out = exportPage(page)
|
||||
const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets: {}, extras })
|
||||
expect(zipBytes.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('actionUrl 지정 시 extras에는 Sheets 템플릿이 포함되지 않는다', () => {
|
||||
const page = makePageWithAction()
|
||||
const extras = buildExtrasForPage(page)
|
||||
expect(Object.keys(extras)).not.toContain('sheets/Code.gs')
|
||||
expect(Object.keys(extras)).not.toContain('sheets/README.txt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { verifyCli } from '@/lib/exporter/verify.cli'
|
||||
|
||||
// helpers to build base64 from string
|
||||
function b64(s: string) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'utf-8').toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.cli', () => {
|
||||
const EMPTY_SHA256_B64 = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||
|
||||
it('returns 0 and logs summary when ok', async () => {
|
||||
// empty entries integrity index; bundleHash = sha256("") in base64
|
||||
const extras = {
|
||||
'assets.integrity.index.json': b64(JSON.stringify({ entries: [], bundleHash: 'sha256-' + EMPTY_SHA256_B64 }))
|
||||
}
|
||||
const assets = {}
|
||||
const extrasJson = JSON.stringify(extras)
|
||||
const assetsJson = JSON.stringify(assets)
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.ok.json': extrasJson,
|
||||
'/tmp/assets.ok.json': assetsJson,
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.ok.json', '--assets', '/tmp/assets.ok.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(0)
|
||||
expect(logs.join('\n')).toMatch(/"ok": true/)
|
||||
expect(logs.join('\n')).toMatch(/"total": 0/)
|
||||
})
|
||||
|
||||
it('returns 1 and logs errors when invalid JSON', async () => {
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.bad.json': JSON.stringify({ 'assets.integrity.index.json': 'not-base64' }),
|
||||
'/tmp/assets.empty.json': JSON.stringify({}),
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.bad.json', '--assets', '/tmp/assets.empty.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(1)
|
||||
expect(logs.join('\n')).toMatch(/"ok": false/)
|
||||
expect(logs.join('\n')).toMatch(/Invalid assets\.integrity\.index\.json JSON|Missing assets\.integrity\.index\.json/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
export type ReadText = (path: string) => Promise<string>
|
||||
export type Logger = (line: string) => void
|
||||
|
||||
// base64 -> Uint8Array
|
||||
function fromBase64(b64: string): Uint8Array {
|
||||
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// parse args like: [--extras, path, --assets, path]
|
||||
function parseArgs(argv: string[]): { extras?: string; assets?: string } {
|
||||
const out: { extras?: string; assets?: string } = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--extras') out.extras = argv[++i]
|
||||
else if (a === '--assets') out.assets = argv[++i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function verifyCli(argv: string[], readText: ReadText, log: Logger): Promise<number> {
|
||||
try {
|
||||
const { extras: extrasPath, assets: assetsPath } = parseArgs(argv)
|
||||
if (!extrasPath || !assetsPath) {
|
||||
log('Usage: verify --extras <extras.json> --assets <assets.json>')
|
||||
return 2
|
||||
}
|
||||
const extrasJson = await readText(extrasPath)
|
||||
const assetsJson = await readText(assetsPath)
|
||||
let extrasMap: Record<string, string>
|
||||
let assetsMap: Record<string, string>
|
||||
try {
|
||||
extrasMap = JSON.parse(extrasJson)
|
||||
assetsMap = JSON.parse(assetsJson)
|
||||
} catch (e) {
|
||||
log('Invalid JSON input files')
|
||||
return 1
|
||||
}
|
||||
// decode base64 into Uint8Array maps
|
||||
const extrasBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(extrasMap)) {
|
||||
try {
|
||||
extrasBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
// mark invalid; let verifyIntegrity handle missing/invalid as possible
|
||||
log(`Failed to decode base64 for extras entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const assetsBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(assetsMap)) {
|
||||
try {
|
||||
assetsBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
log(`Failed to decode base64 for asset entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const result = verifyIntegrity(extrasBytes, assetsBytes)
|
||||
log(JSON.stringify(result, null, 2))
|
||||
return result.ok ? 0 : 1
|
||||
} catch (e) {
|
||||
log(String(e instanceof Error ? e.message : e))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
function te(s: string){ return new TextEncoder().encode(s) }
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns ok=true for valid assets and integrity index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
const a = await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
const b = await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
const extras: Record<string, Uint8Array> = { 'assets.integrity.index.json': idx }
|
||||
|
||||
const res = verifyIntegrity(extras, assets)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.errors.length).toBe(0)
|
||||
// summary
|
||||
const parsedIdx = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
expect(res.summary.total).toBe(parsedIdx.entries.length)
|
||||
expect(res.summary.missing).toBe(0)
|
||||
expect(res.summary.integrityMismatches).toBe(0)
|
||||
expect(res.summary.sizeMismatches).toBe(0)
|
||||
expect(res.summary.bundleHashMismatch).toBe(false)
|
||||
// sanity: index contains both paths
|
||||
const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
const paths = parsed.entries.map(e => e.path)
|
||||
expect(paths).toContain(a.path)
|
||||
expect(paths).toContain(b.path)
|
||||
})
|
||||
|
||||
it('detects byte tampering in an asset', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
// tamper one asset byte
|
||||
const somePath = Object.keys(assets)[0]
|
||||
const tampered = assets[somePath].slice()
|
||||
tampered[0] = (tampered[0] ^ 0xff) & 0xff
|
||||
assets[somePath] = tampered
|
||||
|
||||
const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true)
|
||||
expect(res.summary.integrityMismatches).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('detects missing entry or missing asset referenced by index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idxRaw = buildAssetsIntegrityIndex(am)
|
||||
const obj = JSON.parse(td(idxRaw)) as { entries: Array<{ path: string; integrity: string }>, bundleHash: string }
|
||||
// remove one entry
|
||||
const removed = obj.entries.shift()!
|
||||
const idx2 = te(JSON.stringify(obj))
|
||||
|
||||
const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets)
|
||||
expect(res1.ok).toBe(false)
|
||||
expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true)
|
||||
expect(res1.summary.bundleHashMismatch).toBe(true)
|
||||
|
||||
// reference a missing path in index
|
||||
obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' })
|
||||
const idx3 = te(JSON.stringify(obj))
|
||||
const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets)
|
||||
expect(res2.ok).toBe(false)
|
||||
expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true)
|
||||
expect(res2.summary.missing).toBeGreaterThanOrEqual(1)
|
||||
// keep lints happy
|
||||
expect(removed).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateVerifyInputs } from '@/lib/exporter/verify.serialize'
|
||||
|
||||
// helper to b64
|
||||
function toB64(u8: Uint8Array) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.serialize (from folder maps)', () => {
|
||||
it('collects assets.integrity.index.json into extras and assets/* into assets map', async () => {
|
||||
// virtual FS
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/assets.integrity.index.json': new TextEncoder().encode(JSON.stringify({ entries: [], bundleHash: 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' })),
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([1,2,3]),
|
||||
'/artifacts/export/assets/fonts/f.woff2': new Uint8Array([4,5,6,7]),
|
||||
}
|
||||
const listDir = async (dir: string): Promise<string[]> => {
|
||||
// return child paths (recursive listing simulated by filtering)
|
||||
return Object.keys(files).filter((p) => p.startsWith(dir)).map((p) => p)
|
||||
}
|
||||
const readFile = async (p: string) => {
|
||||
const v = files[p]
|
||||
if (!v) throw new Error('not found: ' + p)
|
||||
return v
|
||||
}
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
// extras must have only the integrity index
|
||||
expect(Object.keys(extras)).toEqual(['assets.integrity.index.json'])
|
||||
expect(extras['assets.integrity.index.json']).toBe(toB64(files['/artifacts/export/assets.integrity.index.json']))
|
||||
// assets must include assets/* files only
|
||||
expect(Object.keys(assets).sort()).toEqual(['assets/fonts/f.woff2','assets/img/a.png'])
|
||||
expect(assets['assets/img/a.png']).toBe(toB64(files['/artifacts/export/assets/img/a.png']))
|
||||
})
|
||||
|
||||
it('ignores non-asset files and missing integrity index', async () => {
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([9,9]),
|
||||
}
|
||||
const listDir = async (dir: string) => Object.keys(files).filter((p) => p.startsWith(dir))
|
||||
const readFile = async (p: string) => files[p]!
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
expect(Object.keys(extras)).toEqual([])
|
||||
expect(Object.keys(assets)).toEqual(['assets/img/a.png'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ListDir = (dir: string) => Promise<string[]>
|
||||
export type ReadFile = (path: string) => Promise<Uint8Array>
|
||||
|
||||
function toBase64(u8: Uint8Array): string {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// Generate inputs for verify CLI from a folder tree.
|
||||
// - root: export folder (contains assets.integrity.index.json and assets/*)
|
||||
// - listDir: returns list of file paths under root (can be recursive listing)
|
||||
// - readFile: reads a file into bytes
|
||||
export async function generateVerifyInputs(
|
||||
root: string,
|
||||
listDir: ListDir,
|
||||
readFile: ReadFile
|
||||
): Promise<{ extras: Record<string, string>; assets: Record<string, string> }> {
|
||||
const all = await listDir(root)
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
const rootNorm = norm(root).replace(/\/$/, '')
|
||||
const rel = (p: string) => {
|
||||
const pn = norm(p)
|
||||
return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn
|
||||
}
|
||||
|
||||
const extras: Record<string, string> = {}
|
||||
const assets: Record<string, string> = {}
|
||||
|
||||
// assets.integrity.index.json at root
|
||||
const integrityPath = all.find((p) => rel(p) === 'assets.integrity.index.json')
|
||||
if (integrityPath) {
|
||||
const bytes = await readFile(integrityPath)
|
||||
extras['assets.integrity.index.json'] = toBase64(bytes)
|
||||
}
|
||||
|
||||
// collect assets/**/*
|
||||
for (const abs of all) {
|
||||
const r = rel(abs)
|
||||
if (!r.startsWith('assets/')) continue
|
||||
// skip directories: best-effort by checking trailing slash
|
||||
if (r.endsWith('/')) continue
|
||||
const bytes = await readFile(abs)
|
||||
assets[r] = toBase64(bytes)
|
||||
}
|
||||
|
||||
return { extras, assets }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export type VerifyResult = {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
summary: {
|
||||
total: number;
|
||||
missing: number;
|
||||
integrityMismatches: number;
|
||||
sizeMismatches: number;
|
||||
bundleHashMismatch: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal synchronous SHA-256 implementation returning base64 string.
|
||||
function sha256Base64(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
])
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
])
|
||||
const l = bytes.length
|
||||
const bitLenHi = Math.floor((l >>> 29))
|
||||
const bitLenLo = (l << 3) >>> 0
|
||||
const withOne = l + 1
|
||||
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
|
||||
const totalLen = withOne + padLen + 8
|
||||
const m = new Uint8Array(totalLen)
|
||||
m.set(bytes)
|
||||
m[l] = 0x80
|
||||
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
|
||||
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
|
||||
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
|
||||
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
|
||||
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
|
||||
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
|
||||
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
|
||||
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
|
||||
const W = new Uint32Array(64)
|
||||
for (let i = 0; i < totalLen; i += 64) {
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const j = i + t * 4
|
||||
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
|
||||
}
|
||||
for (let t = 16; t < 64; t++) {
|
||||
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
|
||||
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
|
||||
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
H[0] = (H[0] + a) >>> 0
|
||||
H[1] = (H[1] + b) >>> 0
|
||||
H[2] = (H[2] + c) >>> 0
|
||||
H[3] = (H[3] + d) >>> 0
|
||||
H[4] = (H[4] + e) >>> 0
|
||||
H[5] = (H[5] + f) >>> 0
|
||||
H[6] = (H[6] + g) >>> 0
|
||||
H[7] = (H[7] + h) >>> 0
|
||||
}
|
||||
const out = new Uint8Array(32)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64')
|
||||
let s = ''; for (let i=0;i<out.length;i++) s += String.fromCharCode(out[i])
|
||||
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => string }).btoa
|
||||
if (!btoaFn) throw new Error('btoa not available')
|
||||
return btoaFn(s)
|
||||
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
|
||||
}
|
||||
|
||||
export function verifyIntegrity(
|
||||
extras: Record<string, Uint8Array>,
|
||||
assets: Record<string, Uint8Array>
|
||||
): VerifyResult {
|
||||
const errors: string[] = []
|
||||
const key = 'assets.integrity.index.json'
|
||||
if (!Object.prototype.hasOwnProperty.call(extras, key)) {
|
||||
return { ok: false, errors: ['Missing assets.integrity.index.json in extras'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string }
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(extras[key]))
|
||||
} catch {
|
||||
return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
// verify each entry exists and integrity matches
|
||||
const recomputedLines: string[] = []
|
||||
let missing = 0
|
||||
let integrityMismatches = 0
|
||||
let sizeMismatches = 0
|
||||
for (const e of parsed.entries) {
|
||||
const bytes = assets[e.path]
|
||||
if (!bytes) {
|
||||
errors.push(`Missing asset for path in integrity index: ${e.path}`)
|
||||
missing++
|
||||
continue
|
||||
}
|
||||
// optional size check when present in index
|
||||
if (typeof e.size === 'number' && e.size !== bytes.length) {
|
||||
errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`)
|
||||
sizeMismatches++
|
||||
}
|
||||
const b64 = sha256Base64(bytes)
|
||||
const integ = `sha256-${b64}`
|
||||
if (integ !== e.integrity) {
|
||||
errors.push(`Integrity mismatch for ${e.path}`)
|
||||
integrityMismatches++
|
||||
}
|
||||
recomputedLines.push(e.path)
|
||||
recomputedLines.push(integ)
|
||||
}
|
||||
// recompute bundle hash
|
||||
const joined = new TextEncoder().encode(recomputedLines.join('\n'))
|
||||
const bundle = `sha256-${sha256Base64(joined)}`
|
||||
const bundleHashMismatch = bundle !== parsed.bundleHash
|
||||
if (bundleHashMismatch) {
|
||||
errors.push('BundleHash mismatch')
|
||||
}
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
summary: {
|
||||
total: parsed.entries.length,
|
||||
missing,
|
||||
integrityMismatches,
|
||||
sizeMismatches,
|
||||
bundleHashMismatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
function u8(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s)
|
||||
}
|
||||
|
||||
describe('buildZip path guard', () => {
|
||||
it('rejects dangerous asset paths with .. segments', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', assets: { '../evil.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid asset path/i)
|
||||
})
|
||||
|
||||
it('rejects dangerous extras with absolute path and backslashes', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { '/abs.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { 'assets\\x.bin': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
})
|
||||
|
||||
it('accepts safe relative paths under assets/', async () => {
|
||||
const zip = await buildZip({ html: '', css: '', js: '', assets: { 'assets/a.bin': u8('ok') } })
|
||||
expect(zip.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
zip.file('index.html', input.html)
|
||||
zip.file('styles.css', input.css)
|
||||
zip.file('app.js', input.js)
|
||||
|
||||
// guard: basic path sanitizer to avoid directory traversal or absolute paths
|
||||
const isSafePath = (p: string) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
if (p.startsWith('/')) return false
|
||||
if (p.includes('..')) return false
|
||||
if (p.includes('\\')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.assets) {
|
||||
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`)
|
||||
// In Node/Vitest, use Buffer for robust binary handling
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
@@ -33,6 +44,7 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
}
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`)
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
|
||||
@@ -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,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,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\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1', width: 320, height: 200, background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } },
|
||||
]},
|
||||
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [
|
||||
{ id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } },
|
||||
]},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => {
|
||||
it('renders wrapper with fixed width/height and absolutely positioned children', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).toMatch(/<div class=\"frame\"/)
|
||||
expect(out.css).toMatch(/\.frame\s*{[^}]*position:relative/)
|
||||
expect(out.css).toMatch(/width:320px/)
|
||||
expect(out.css).toMatch(/height:200px/)
|
||||
// image object
|
||||
expect(out.html).toMatch(/<img[^>]*src=\"https:\/\/example.com\/a.jpg\"/)
|
||||
expect(out.css).toMatch(/#o1\s*{[^}]*left:16px;[^}]*top:24px;[^}]*width:80px;[^}]*height:60px;[^}]*transform:rotate\(15deg\)/)
|
||||
// text object
|
||||
expect(out.html).toMatch(/<p id=\"t1\"/)
|
||||
expect(out.html).toMatch(/Hello/)
|
||||
expect(out.css).toMatch(/#t1\s*{[^}]*left:24px;[^}]*top:120px;[^}]*width:200px;[^}]*height:32px/)
|
||||
})
|
||||
|
||||
it('includes scale wrapper token for responsive fit', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/)
|
||||
expect(out.html).toMatch(/class=\"frame-scale\"/)
|
||||
})
|
||||
|
||||
it('wraps exported markup with full HTML document and references stylesheet/script', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html.startsWith('<!doctype html>')).toBe(true)
|
||||
expect(out.html).toMatch(/<link rel=\"stylesheet\" href=\"styles\.css\"/)
|
||||
expect(out.html).toMatch(/<script src=\"app\.js\"><\/script>/)
|
||||
})
|
||||
|
||||
it('exports button objects as anchors with href/label semantics', () => {
|
||||
const frame: Frame = {
|
||||
...makeFrame(),
|
||||
layers: [
|
||||
{
|
||||
id: 'buttons',
|
||||
name: 'Buttons',
|
||||
visible: true,
|
||||
locked: false,
|
||||
objects: [
|
||||
{ id: 'cta', type: 'button', x: 10, y: 20, width: 120, height: 36, rotate: 0, zIndex: 0, props: { label: 'Start', href: 'https://acme.dev', color: '#fff', background: '#111' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).toMatch(/<a id=\"cta\" href=\"https:\/\/acme\.dev\">Start<\/a>/)
|
||||
expect(out.css).toMatch(/#cta\s*{[^}]*width:120px;[^}]*height:36px/)
|
||||
})
|
||||
|
||||
it('skips invisible layers when exporting html/css', () => {
|
||||
const frame: Frame = {
|
||||
...makeFrame(),
|
||||
layers: [
|
||||
{ id: 'hidden', name: 'Hidden', visible: false, locked: false, objects: [makeFrame().layers[0].objects[0]] },
|
||||
],
|
||||
}
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).not.toContain('id="o1"')
|
||||
expect(out.css).not.toContain('#o1')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user