Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7fa433bdd | |||
| 8f51494433 | |||
| e602b16254 | |||
| e4287b8567 |
@@ -248,6 +248,36 @@
|
||||
- 이동하지 않은 축은 기존 좌표 유지하여 비의도 축 보정 방지
|
||||
- 결과: 전체 테스트 그린(168 파일 / 295 테스트)
|
||||
|
||||
### CI E2E 수정: 마퀴 시작 판정 완화 (2025-11-17)
|
||||
- 원인: 빈 영역 드래그 시에도 마퀴가 시작해야 하나, 캔버스 data-testid 직접 비교로 인해 실제 히트박스에서 시작 누락 → aria-live "선택 0개" 유지
|
||||
- 조치: `components/wysiwyg/Canvas.tsx`의 `onMouseDown`에서 객체/핸들 근접 여부 검사(`closest`)로 판정 완화, 객체/핸들이 아닌 영역이면 항상 마퀴 시작
|
||||
- 결과: E2E 라이브리전 멀티선택 문구 안정화, 전체 테스트 그린 유지
|
||||
|
||||
### E2E 스모크: 그룹 드래그 스냅 델타 동일 적용 (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-group-drag.e2e.ts`
|
||||
- 마퀴로 2개 선택 후, 선택 중 하나를 +16px 드래그 → 8px 스냅 반영 동일 델타로 양 객체 이동 검증
|
||||
- 기대/결과: 스냅된 델타(dx)가 선택 전체에 동일하게 적용됨 확인
|
||||
|
||||
### E2E 스모크 확장: 그룹 리사이즈(E)/회전(15°) + 가이드 라이브리전 (2025-11-17)
|
||||
- 테스트: `tests/e2e/wysiwyg-group-resize-rotate.e2e.ts`
|
||||
- 그룹 E 핸들 리사이즈: 좌표 고정, width만 스냅 증가, 두 객체 동일 증분
|
||||
- 회전 핸들: 15° 단위 스냅, 드래그 중 라이브리전에 가이드 좌표 문구 포함
|
||||
- 결과: 그룹 액션 스냅/가이드 UX 안정화 스모크 통과
|
||||
|
||||
### 이미지 최적화 옵션 2차 TDD (2025-11-17)
|
||||
- 테스트: `lib/exporter/image.responsive.options.matrix.test.ts`
|
||||
- loading/decoding/fetchpriority 적용
|
||||
- srcset + autoSizes + preset 동시 제공 시 preset 우선
|
||||
- explicit sizes가 있으면 preset/autoSizes보다 우선
|
||||
- 구현 상태: `lib/exporter/html.ts`의 기존 로직이 요구사항 충족(우선순위: explicit > preset > default(autoSizes))
|
||||
|
||||
### Export CSS 토큰 딥 회귀 (폼/테마) (2025-11-17)
|
||||
- 테스트: `lib/wysiwyg/export.style.tokens.form.deep.test.ts`
|
||||
- 토큰 존재: `--ring`, `--ring-offset`, `--disabled-bg`, `--disabled-fg`
|
||||
- 사용 검증: `:focus-visible` 링/오프셋, `:disabled` BG, `[readonly]` FG
|
||||
- 테마 프리셋: `[data-theme="light|dark"]`에서 `--bg` 제공, `.frame{background:var(--bg)}` 유지
|
||||
- 결과: 토큰/참조/테마 오버라이드 회귀 보강, 전체 테스트 그린(170 파일/300 테스트)
|
||||
|
||||
## 목표
|
||||
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
||||
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
||||
|
||||
@@ -434,9 +434,11 @@ export function Canvas({ frame, onChange, onSelectIds, enableGuides = true }: Ca
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => {
|
||||
// 빈 캔버스 클릭 시 마퀴 시작 또는 선택 해제
|
||||
// 객체 onMouseDown에서 선택을 처리하므로, 여기서는 드래그 박스 시작만 담당
|
||||
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
|
||||
// 빈 영역(객체/핸들이 아닌 영역) 클릭 시 마퀴 시작 또는 선택 해제
|
||||
const t = e.target as HTMLElement
|
||||
const isObj = !!t.closest('[data-testid^="obj-"],[aria-label^="object "]')
|
||||
const isHandle = !!t.closest('[data-testid^="resize-handle-"]')
|
||||
if (!isObj && !isHandle) {
|
||||
setSelectedIds([])
|
||||
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
|
||||
try { onSelectIds?.([]) } catch {}
|
||||
|
||||
@@ -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,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
async function getLefts(page: import('@playwright/test').Page) {
|
||||
const els = page.locator('[data-testid^="obj-text-"]')
|
||||
const count = await els.count()
|
||||
const results: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const style = await els.nth(i).evaluate((n) => (n as HTMLElement).style.left || '0')
|
||||
results.push(parseInt(style || '0', 10))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Group drag', () => {
|
||||
test('marquee select two items and drag group by 16px on X (snaps to grid)', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('canvas not found')
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + 240, box.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
const before = await getLefts(page)
|
||||
|
||||
// Drag one of selected (the second) by +16 on X
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + 21, tbox.y + 5) // ~+16 on X
|
||||
await page.mouse.up()
|
||||
|
||||
const after = await getLefts(page)
|
||||
// Expect both moved by same snapped delta
|
||||
const expectedDx = snap8(before[1] + 16) - before[1]
|
||||
expect(after.length).toBeGreaterThanOrEqual(2)
|
||||
expect(after[0]).toBe(before[0] + expectedDx)
|
||||
expect(after[1]).toBe(before[1] + expectedDx)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const wysiwygUrl = '/en/wysiwyg'
|
||||
|
||||
function snap8(v: number) { return Math.round(v / 8) * 8 }
|
||||
|
||||
async function getBoxStyles(page: import('@playwright/test').Page, nth: number) {
|
||||
const el = page.locator('[data-testid^="obj-text-"]').nth(nth)
|
||||
const left = await el.evaluate((n) => parseInt((n as HTMLElement).style.left || '0', 10))
|
||||
const top = await el.evaluate((n) => parseInt((n as HTMLElement).style.top || '0', 10))
|
||||
const width = await el.evaluate((n) => parseInt((n as HTMLElement).style.width || '0', 10))
|
||||
const height = await el.evaluate((n) => parseInt((n as HTMLElement).style.height || '0', 10))
|
||||
const transform = await el.evaluate((n) => (n as HTMLElement).style.transform || '')
|
||||
return { left, top, width, height, transform }
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG - Group resize/rotate and guide live region', () => {
|
||||
test('group E handle resizes both by snapped delta without shifting x', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
await addText.click()
|
||||
|
||||
// Marquee select both
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('canvas not found')
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + 260, box.y + 160)
|
||||
await page.mouse.up()
|
||||
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/선택\s*2개/)
|
||||
|
||||
const b0 = await getBoxStyles(page, 0)
|
||||
const b1 = await getBoxStyles(page, 1)
|
||||
|
||||
// Drag east handle of second by +16px
|
||||
const target = page.locator('[data-testid^="obj-text-"]').nth(1)
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
await page.mouse.move(tbox.x + tbox.width - 5, tbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tbox.x + tbox.width + 16, tbox.y + 5)
|
||||
await page.mouse.up()
|
||||
|
||||
const a0 = await getBoxStyles(page, 0)
|
||||
const a1 = await getBoxStyles(page, 1)
|
||||
|
||||
const dw = snap8(b1.width + 16) - b1.width
|
||||
expect(a0.left).toBe(b0.left)
|
||||
expect(a1.left).toBe(b1.left)
|
||||
expect(a0.width).toBe(b0.width + dw)
|
||||
expect(a1.width).toBe(b1.width + dw)
|
||||
})
|
||||
|
||||
test('rotate handle snaps to 15deg and live region includes guide coords while dragging', async ({ page }) => {
|
||||
await page.goto(wysiwygUrl)
|
||||
const canvas = page.getByTestId('wysiwyg-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const addText = page.getByRole('button', { name: /^Add Text$/i })
|
||||
await addText.click()
|
||||
|
||||
const target = page.locator('[data-testid^="obj-text-"]').first()
|
||||
const tbox = await target.boundingBox()
|
||||
if (!tbox) throw new Error('target not found')
|
||||
|
||||
// Select
|
||||
await target.click()
|
||||
|
||||
// Drag rotate handle horizontally to cause rotation ~15deg (snapped)
|
||||
const rotateHandle = page.locator('[data-testid^="rotate-handle-"]').first()
|
||||
const hbox = await rotateHandle.boundingBox()
|
||||
if (!hbox) throw new Error('rotate handle not found')
|
||||
await page.mouse.move(hbox.x + 5, hbox.y + 5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hbox.x + 40, hbox.y + 5)
|
||||
const live = page.getByTestId('aria-live')
|
||||
await expect(live).toContainText(/가이드\s*x=|y=/)
|
||||
await page.mouse.up()
|
||||
|
||||
const a = await getBoxStyles(page, 0)
|
||||
expect(a.transform).toMatch(/rotate\(15deg\)|rotate\(30deg\)/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user