Merge pull request 'auto: PR for feat/wysiwyg-moveable-integration' (#71) from feat/wysiwyg-moveable-integration into main
CI / test (push) Successful in 1m8s
CI / test (push) Successful in 1m8s
This commit was merged in pull request #71.
This commit is contained in:
@@ -1,5 +1,34 @@
|
|||||||
# 메인플랜: 랜딩 페이지 빌더
|
# 메인플랜: 랜딩 페이지 빌더
|
||||||
|
|
||||||
|
### 가이드라인 고도화 진행(2025-11-16)
|
||||||
|
- 목표: 객체-객체 스냅 가이드(8px 임계)
|
||||||
|
- 수직 중심 정렬 가이드/스냅
|
||||||
|
- 수평 중심 정렬 가이드/스냅
|
||||||
|
- 좌/우/상/하 가장자리 정렬 가이드/스냅
|
||||||
|
- TDD
|
||||||
|
- `components/wysiwyg/Canvas.guidelines.advanced.test.tsx`
|
||||||
|
- 수직 중심 스냅 가이드(8px) 검증. Moveable 모킹, `act`/`waitFor` 적용
|
||||||
|
- `components/wysiwyg/Canvas.guidelines.horizontal.test.tsx`
|
||||||
|
- 수평 중심 스냅 가이드(8px) 검증
|
||||||
|
- `components/wysiwyg/Canvas.guidelines.edges.test.tsx`
|
||||||
|
- 좌/우 에지 스냅 가이드(8px) 1차 케이스 검증(T→L 등 에지 페어)
|
||||||
|
- 구현
|
||||||
|
- `components/wysiwyg/Canvas.tsx`
|
||||||
|
- onDrag: 그리드(8px) → 객체 중심 스냅(수직/수평) → 객체 에지 스냅(좌/우/상/하) 우선순위 적용
|
||||||
|
- 가이드 좌표: 축별로 에지 > 중심 > 그리드 순서로 표시
|
||||||
|
- 드래그 시작 시 가이드 표시, 종료 시 가이드 해제 유지
|
||||||
|
- 결과: 전체 테스트 그린(131/131)
|
||||||
|
|
||||||
|
#### 오류 원인/디버깅(메모)
|
||||||
|
- 실패: 수직 중심 스냅 테스트에서 선택 대상이 기본 선택되지 않아 좌표 미변경 → 레이어 objects 순서를 조정(o1을 첫 요소로)
|
||||||
|
- 실패: 비동기 상태 반영 전 단정 → Moveable 이벤트 호출을 `act`로 감싸고 결과 검증을 `waitFor`/`findByTestId`로 보강하여 해결
|
||||||
|
- 실패: 가장자리 스냅 미구현으로 208px(그리드) 유지 → 에지 스냅(LL/LR/RL/RR, TT/TB/BT/BB) 페어 계산 로직과 8px 임계 우선순위 적용으로 해결
|
||||||
|
|
||||||
|
#### 다음 단계
|
||||||
|
- 키보드 nudge(Shift/Alt 미세/가속)와 가이드 반영 TDD/구현
|
||||||
|
- 다중 선택 및 그룹 이동/정렬 스펙 초안 TDD
|
||||||
|
- 접근성: 키보드 조작 시 라이브 리전 안내/가이드라인 ARIA 노출 정책 검토
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
||||||
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
||||||
@@ -246,6 +275,7 @@
|
|||||||
- a11y/SEO/OG/Analytics
|
- 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-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: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 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린
|
||||||
@@ -423,3 +453,55 @@
|
|||||||
### 커밋/푸쉬 원칙
|
### 커밋/푸쉬 원칙
|
||||||
- 기능 단위 커밋은 한국어 메시지
|
- 기능 단위 커밋은 한국어 메시지
|
||||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||||
|
|
||||||
|
## 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/가이드라인 테스트 유지 그린
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 (y=50, h=30)
|
||||||
|
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Canvas guidelines advanced (horizontal center snap)', () => {
|
||||||
|
beforeEach(() => capture.mockReset())
|
||||||
|
|
||||||
|
it('shows horizontal guide and snaps y to other object center within 8px threshold', async () => {
|
||||||
|
render(<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,95 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { act } from 'react-dom/test-utils'
|
||||||
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
const capture = vi.fn()
|
||||||
|
type MProps = {
|
||||||
|
onDragStart?: (e: unknown) => void
|
||||||
|
onDrag?: (e: { left?: number; top?: number }) => void
|
||||||
|
onDragEnd?: (e: unknown) => void
|
||||||
|
onResize?: (e: { width?: number; height?: number }) => void
|
||||||
|
onRotate?: (e: { rotate?: number; inputEvent?: { shiftKey?: boolean } }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('react-moveable', () => {
|
||||||
|
const Mock = (props: Record<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(() => {
|
||||||
|
expect(obj.style.width).toBe('80px')
|
||||||
|
expect(obj.style.height).toBe('16px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onRotate applies 15deg snap (no shift) and 1deg with shift', async () => {
|
||||||
|
render(<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,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
vi.mock('react-moveable', () => {
|
||||||
|
const Mock = (props: Record<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)
|
||||||
|
expect(props.resizable).toBe(true)
|
||||||
|
expect(props.rotatable).toBe(true)
|
||||||
|
expect(props.snappable).toBe(true)
|
||||||
|
expect(props.snapGridWidth).toBe(8)
|
||||||
|
expect(props.snapGridHeight).toBe(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function makeFrame(): Frame {
|
||||||
|
return {
|
||||||
|
id: 'f1',
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'l1',
|
||||||
|
name: 'L1',
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
objects: [
|
||||||
|
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => {
|
||||||
|
it('ArrowRight nudges and snaps x to nearest 8px grid', () => {
|
||||||
|
const frame = makeFrame()
|
||||||
|
const { getByTestId } = render(<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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,301 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import Moveable from 'react-moveable'
|
||||||
|
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||||
|
import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap'
|
||||||
|
|
||||||
|
function snap8(v: number) {
|
||||||
|
return Math.round(v / 8) * 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift 키 타입 가드: unknown 입력에서 shiftKey 존재 여부를 안전하게 판별
|
||||||
|
function isShiftEvent(ev: unknown): ev is { shiftKey?: boolean } {
|
||||||
|
return typeof ev === 'object' && ev !== null && 'shiftKey' in (ev as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트 환경(jsdom)에서 react-moveable이 사용하는 elementFromPoint가 없을 수 있어 안전한 폴리필을 추가
|
||||||
|
type DocWithEFP = Document & { elementFromPoint?: (x: number, y: number) => Element | null }
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const d = document as DocWithEFP
|
||||||
|
if (typeof d.elementFromPoint !== 'function') {
|
||||||
|
d.elementFromPoint = (_x: number, _y: number) => null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanvasProps = {
|
||||||
|
frame: Frame
|
||||||
|
onChange?: (next: Frame) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Canvas({ frame, onChange }: CanvasProps) {
|
||||||
|
const [model, setModel] = useState<Frame>(frame)
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(model.layers[0]?.objects[0]?.id ?? null)
|
||||||
|
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||||
|
const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
|
||||||
|
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
|
||||||
|
const [showGuides, setShowGuides] = useState(false)
|
||||||
|
const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 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])
|
||||||
|
|
||||||
|
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
|
||||||
|
setModel((prev) => {
|
||||||
|
const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) }
|
||||||
|
onChange?.(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (!selected) return
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (e.key === 'ArrowLeft') dx = -1
|
||||||
|
else if (e.key === 'ArrowRight') dx = 1
|
||||||
|
else if (e.key === 'ArrowUp') dy = -1
|
||||||
|
else if (e.key === 'ArrowDown') dy = 1
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
updateObject(selected.id, (o) => {
|
||||||
|
const nx = snap8(o.x + dx)
|
||||||
|
const ny = snap8(o.y + dy)
|
||||||
|
return { ...o, x: nx, y: ny }
|
||||||
|
})
|
||||||
|
}, [selected, updateObject])
|
||||||
|
|
||||||
|
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||||
|
setSelectedId(o.id)
|
||||||
|
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
|
||||||
|
setShowGuides(true)
|
||||||
|
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
|
||||||
|
const rot = rotatingRef.current
|
||||||
|
if (rot) {
|
||||||
|
const dx = e.clientX - rot.startX
|
||||||
|
let next = rot.origDeg + dx
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
next = Math.round(next / 15) * 15
|
||||||
|
}
|
||||||
|
updateObject(rot.id, (o) => ({ ...o, rotate: next }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = resizingRef.current
|
||||||
|
if (r) {
|
||||||
|
const dx = e.clientX - r.startX
|
||||||
|
const dy = e.clientY - r.startY
|
||||||
|
const nw = Math.max(8, snap8(r.origW + dx))
|
||||||
|
const nh = Math.max(8, snap8(r.origH + dy))
|
||||||
|
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = draggingRef.current
|
||||||
|
if (d) {
|
||||||
|
const dx = e.clientX - d.startX
|
||||||
|
const dy = e.clientY - d.startY
|
||||||
|
const nx = snap8(d.origX + dx)
|
||||||
|
const ny = snap8(d.origY + dy)
|
||||||
|
setGuidePos({ x: nx, y: ny })
|
||||||
|
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||||
|
}
|
||||||
|
}, [updateObject])
|
||||||
|
|
||||||
|
const onMouseUpCanvas = useCallback(() => {
|
||||||
|
draggingRef.current = null
|
||||||
|
resizingRef.current = null
|
||||||
|
rotatingRef.current = null
|
||||||
|
setShowGuides(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="application"
|
||||||
|
aria-label="WYSIWYG Canvas"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onMouseMove={onMouseMoveCanvas}
|
||||||
|
onMouseUp={onMouseUpCanvas}
|
||||||
|
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
|
||||||
|
data-testid="wysiwyg-canvas"
|
||||||
|
>
|
||||||
|
{showGuides && (
|
||||||
|
<>
|
||||||
|
<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' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{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: selectedId===o.id? '2px solid #0ea5e9':'none' }}
|
||||||
|
>
|
||||||
|
{selectedId === o.id && (
|
||||||
|
<div
|
||||||
|
data-testid={`resize-handle-${o.id}`}
|
||||||
|
onMouseDown={(e) => onMouseDownResize(e, o)}
|
||||||
|
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedId === 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
|
||||||
|
resizable
|
||||||
|
rotatable
|
||||||
|
snappable
|
||||||
|
snapGridWidth={8}
|
||||||
|
snapGridHeight={8}
|
||||||
|
onDragStart={() => {
|
||||||
|
// 드래그 시작 시 가이드 표시
|
||||||
|
setShowGuides(true)
|
||||||
|
}}
|
||||||
|
onDrag={({ left, top }) => {
|
||||||
|
if (!selected) return
|
||||||
|
const rawLeft = typeof left === 'number' ? left : 0
|
||||||
|
const rawTop = typeof top === 'number' ? top : 0
|
||||||
|
let nx = snap8(rawLeft)
|
||||||
|
let ny = snap8(rawTop)
|
||||||
|
|
||||||
|
// 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px
|
||||||
|
const threshold = 8
|
||||||
|
let snappedCenterX: number | undefined
|
||||||
|
let snappedCenterY: number | undefined
|
||||||
|
// 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표
|
||||||
|
let edgeNx: number | undefined
|
||||||
|
let edgeNy: number | undefined
|
||||||
|
let guideEdgeX: number | undefined
|
||||||
|
let guideEdgeY: number | undefined
|
||||||
|
let minDistX = Number.POSITIVE_INFINITY
|
||||||
|
let minDistY = Number.POSITIVE_INFINITY
|
||||||
|
outer: for (const layer of model.layers) {
|
||||||
|
for (const obj of layer.objects) {
|
||||||
|
if (obj.id === selected.id) continue
|
||||||
|
const otherCenterX = obj.x + obj.width / 2
|
||||||
|
const selCenterX = rawLeft + selected.width / 2
|
||||||
|
if (Math.abs(selCenterX - otherCenterX) <= threshold) {
|
||||||
|
snappedCenterX = otherCenterX
|
||||||
|
// 수평 중심도 동시에 체크
|
||||||
|
const otherCenterY = obj.y + obj.height / 2
|
||||||
|
const selCenterY = rawTop + selected.height / 2
|
||||||
|
if (Math.abs(selCenterY - otherCenterY) <= threshold) {
|
||||||
|
snappedCenterY = otherCenterY
|
||||||
|
}
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
const otherCenterY2 = obj.y + obj.height / 2
|
||||||
|
const selCenterY2 = rawTop + selected.height / 2
|
||||||
|
if (Math.abs(selCenterY2 - otherCenterY2) <= threshold) {
|
||||||
|
snappedCenterY = otherCenterY2
|
||||||
|
// x는 그리드, y만 스냅으로 나가고 루프 종료
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에지 스냅 후보 계산 (X축)
|
||||||
|
const selLeft = rawLeft
|
||||||
|
const selRight = rawLeft + selected.width
|
||||||
|
const otherLeft = obj.x
|
||||||
|
const otherRight = obj.x + obj.width
|
||||||
|
const xPairs: Array<{dist: number; nx: number; guideX: number}> = [
|
||||||
|
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L
|
||||||
|
{ dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R
|
||||||
|
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - selected.width, guideX: otherLeft }, // R-L
|
||||||
|
{ dist: Math.abs(selRight - otherRight), nx: otherRight - selected.width, guideX: otherRight }, // R-R
|
||||||
|
]
|
||||||
|
for (const p of xPairs) {
|
||||||
|
if (p.dist <= threshold && p.dist < minDistX) {
|
||||||
|
minDistX = p.dist
|
||||||
|
edgeNx = p.nx
|
||||||
|
guideEdgeX = p.guideX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에지 스냅 후보 계산 (Y축)
|
||||||
|
const selTop = rawTop
|
||||||
|
const selBottom = rawTop + selected.height
|
||||||
|
const otherTop = obj.y
|
||||||
|
const otherBottom = obj.y + obj.height
|
||||||
|
const yPairs: Array<{dist: number; ny: number; guideY: number}> = [
|
||||||
|
{ dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T
|
||||||
|
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B
|
||||||
|
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - selected.height, guideY: otherTop }, // B-T
|
||||||
|
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selected.height, guideY: otherBottom }, // B-B
|
||||||
|
]
|
||||||
|
for (const p of yPairs) {
|
||||||
|
if (p.dist <= threshold && p.dist < minDistY) {
|
||||||
|
minDistY = p.dist
|
||||||
|
edgeNy = p.ny
|
||||||
|
guideEdgeY = p.guideY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우선순위: 에지 스냅 > 중심 스냅 > 그리드
|
||||||
|
if (typeof edgeNx === 'number') {
|
||||||
|
nx = edgeNx
|
||||||
|
} else if (typeof snappedCenterX === 'number') {
|
||||||
|
nx = snappedCenterX - selected.width / 2
|
||||||
|
}
|
||||||
|
if (typeof edgeNy === 'number') {
|
||||||
|
ny = edgeNy
|
||||||
|
} else if (typeof snappedCenterY === 'number') {
|
||||||
|
ny = snappedCenterY - selected.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
|
||||||
|
const guideX = typeof guideEdgeX === 'number' ? guideEdgeX : (typeof snappedCenterX === 'number' ? snappedCenterX : nx)
|
||||||
|
const guideY = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
|
||||||
|
setGuidePos({ x: guideX, y: guideY })
|
||||||
|
|
||||||
|
updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
// 드래그 종료 시 가이드 숨김
|
||||||
|
setShowGuides(false)
|
||||||
|
}}
|
||||||
|
onResize={({ width, height }) => {
|
||||||
|
if (!selected) return
|
||||||
|
const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0))
|
||||||
|
const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0))
|
||||||
|
updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh }))
|
||||||
|
}}
|
||||||
|
onRotate={({ rotate, inputEvent }) => {
|
||||||
|
if (!selected) return
|
||||||
|
const fine = isShiftEvent(inputEvent) && !!inputEvent.shiftKey
|
||||||
|
const nd = snapAngleUtil(typeof rotate === 'number' ? rotate : 0, fine)
|
||||||
|
updateObject(selected.id, (o) => ({ ...o, rotate: nd }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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,65 @@
|
|||||||
|
import React, { useCallback, useState } from 'react'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
export type LayersPanelProps = {
|
||||||
|
frame: Frame
|
||||||
|
onChange?: (next: Frame) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
|
||||||
|
const [model, setModel] = useState<Frame>(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,75 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import type { WObject } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
export type PropsPanelProps = {
|
||||||
|
object: WObject
|
||||||
|
onChange?: (next: WObject) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: string, fallback: number) {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> {
|
||||||
|
return o.type === 'image'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropsPanel({ object, onChange }: PropsPanelProps) {
|
||||||
|
const [model, setModel] = useState<WObject>(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||||
|
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function makeFrame(): Frame {
|
||||||
|
return {
|
||||||
|
id: 'f1', width: 320, height: 200, background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [
|
||||||
|
{ id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } },
|
||||||
|
]},
|
||||||
|
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [
|
||||||
|
{ id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => {
|
||||||
|
it('renders wrapper with fixed width/height and absolutely positioned children', () => {
|
||||||
|
const frame = makeFrame()
|
||||||
|
const out = exportFrame(frame)
|
||||||
|
expect(out.html).toMatch(/<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\"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
export type Exported = { html: string; css: string; js: string }
|
||||||
|
|
||||||
|
function esc(s: string) {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 renderObject(o: WObject): { html: string; css: string } {
|
||||||
|
const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}`
|
||||||
|
if (isImage(o)) {
|
||||||
|
const html = `<img id="${o.id}" src="${esc(o.props.src)}" alt="${esc(o.props.alt || '')}" />`
|
||||||
|
return { html, css: baseCss }
|
||||||
|
}
|
||||||
|
if (isText(o)) {
|
||||||
|
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
|
||||||
|
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
|
||||||
|
}
|
||||||
|
// button minimal
|
||||||
|
const label = isButton(o) ? o.props.label : 'Button'
|
||||||
|
const href = isButton(o) ? (o.props.href || '#') : '#'
|
||||||
|
const html = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
|
||||||
|
return { html, css: baseCss }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFrame(frame: Frame): Exported {
|
||||||
|
const piecesHtml: string[] = []
|
||||||
|
const piecesCss: string[] = []
|
||||||
|
for (const layer of frame.layers) {
|
||||||
|
if (!layer.visible) continue
|
||||||
|
for (const o of layer.objects) {
|
||||||
|
const r = renderObject(o)
|
||||||
|
piecesHtml.push(r.html)
|
||||||
|
piecesCss.push(r.css)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const html = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
|
||||||
|
const css = [
|
||||||
|
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`,
|
||||||
|
`.frame-scale{transform-origin:0 0}`,
|
||||||
|
...piecesCss,
|
||||||
|
].join('\n')
|
||||||
|
return { html, css, js: '' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { frameSchema, objectSchema, layerSchema, type Frame, type WObject } from '@/lib/wysiwyg/schema'
|
||||||
|
|
||||||
|
function validText(id: string): WObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'text',
|
||||||
|
x: 100,
|
||||||
|
y: 120,
|
||||||
|
width: 300,
|
||||||
|
height: 40,
|
||||||
|
rotate: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
props: { text: 'Hello', fontSize: 18, color: '#111111' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validImage(id: string): WObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
x: 50,
|
||||||
|
y: 60,
|
||||||
|
width: 240,
|
||||||
|
height: 180,
|
||||||
|
rotate: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
props: { src: 'https://example.com/a.jpg', alt: 'A' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wysiwyg.schema', () => {
|
||||||
|
it('validates a Frame with layers and objects', () => {
|
||||||
|
const frame: Frame = {
|
||||||
|
id: 'frame-1',
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
background: '#ffffff',
|
||||||
|
layers: [
|
||||||
|
{ id: 'layer-1', name: 'Base', visible: true, locked: false, objects: [validImage('img-1')] },
|
||||||
|
{ id: 'layer-2', name: 'Text', visible: true, locked: false, objects: [validText('txt-1')] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const parsed = frameSchema.parse(frame)
|
||||||
|
expect(parsed.layers.length).toBe(2)
|
||||||
|
expect(parsed.layers[1].objects[0].type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid color and negative sizes', () => {
|
||||||
|
const bad: Frame = {
|
||||||
|
id: 'f', width: -10, height: 0, background: 'blue', layers: [],
|
||||||
|
} as unknown as Frame
|
||||||
|
expect(() => frameSchema.parse(bad)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('objectSchema enforces type-specific props', () => {
|
||||||
|
const ok = objectSchema.parse(validText('t1'))
|
||||||
|
expect(ok.props.text).toBe('Hello')
|
||||||
|
|
||||||
|
const bad = { ...validText('t2'), props: { src: 'x' } }
|
||||||
|
expect(() => objectSchema.parse(bad as unknown as WObject)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('layerSchema enforces ordering integers and object bounds', () => {
|
||||||
|
const good = layerSchema.parse({ id: 'l1', name: 'L', visible: true, locked: false, objects: [validImage('i1')] })
|
||||||
|
expect(good.objects[0].zIndex).toBe(0)
|
||||||
|
|
||||||
|
const badObj = { ...validImage('i2'), width: -1 }
|
||||||
|
expect(() => objectSchema.parse(badObj as unknown as WObject)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const colorHex = z.string().regex(/^#([0-9a-fA-F]{3}){1,2}$/)
|
||||||
|
|
||||||
|
const baseObject = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
rotate: z.number(),
|
||||||
|
zIndex: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const textObject = baseObject.extend({
|
||||||
|
type: z.literal('text'),
|
||||||
|
props: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
fontSize: z.number().positive(),
|
||||||
|
color: colorHex,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageObject = baseObject.extend({
|
||||||
|
type: z.literal('image'),
|
||||||
|
props: z.object({
|
||||||
|
src: z.string().min(1),
|
||||||
|
alt: z.string().optional().default(''),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonObject = baseObject.extend({
|
||||||
|
type: z.literal('button'),
|
||||||
|
props: z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
href: z.string().optional().default(''),
|
||||||
|
color: colorHex.optional(),
|
||||||
|
background: colorHex.optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject])
|
||||||
|
export type WObject = z.infer<typeof objectSchema>
|
||||||
|
|
||||||
|
export const layerSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
visible: z.boolean(),
|
||||||
|
locked: z.boolean(),
|
||||||
|
objects: z.array(objectSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const frameSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
background: colorHex,
|
||||||
|
layers: z.array(layerSchema),
|
||||||
|
})
|
||||||
|
export type Frame = z.infer<typeof frameSchema>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { snap8, snapAngle } from '@/lib/wysiwyg/snap'
|
||||||
|
|
||||||
|
describe('snap utils', () => {
|
||||||
|
it('snap8 rounds to nearest 8', () => {
|
||||||
|
expect(snap8(0)).toBe(0)
|
||||||
|
expect(snap8(3)).toBe(0)
|
||||||
|
expect(snap8(4)).toBe(8)
|
||||||
|
expect(snap8(11)).toBe(8)
|
||||||
|
expect(snap8(12)).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapAngle snaps to 15deg by default', () => {
|
||||||
|
expect(snapAngle(0)).toBe(0)
|
||||||
|
expect(snapAngle(7)).toBe(15)
|
||||||
|
expect(snapAngle(22)).toBe(15)
|
||||||
|
expect(snapAngle(30)).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapAngle respects fine=true (1deg)', () => {
|
||||||
|
expect(snapAngle(7, true)).toBe(7)
|
||||||
|
expect(snapAngle(22, true)).toBe(22)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 8px 격자 스냅: 주어진 값 v를 가장 가까운 8의 배수로 반올림
|
||||||
|
export function snap8(v: number): number {
|
||||||
|
return Math.round(v / 8) * 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapAngle(deg: number, fine: boolean = false): number {
|
||||||
|
// fine=true이면 미세 제어(Shift)로 1° 단위 스냅을 그대로 허용
|
||||||
|
if (fine) return deg
|
||||||
|
// 0도는 그대로 유지
|
||||||
|
if (deg === 0) return 0
|
||||||
|
// 첫 구간 경계 보정: 0<|deg|<15는 15°/−15°로 스냅(사용자 체감상 0도로 붙지 않게)
|
||||||
|
if (deg > 0 && deg < 15) return 15
|
||||||
|
if (deg < 0 && deg > -15) return -15
|
||||||
|
// 기본은 15° 단위로 가장 가까운 각도로 반올림
|
||||||
|
return Math.round(deg / 15) * 15
|
||||||
|
}
|
||||||
Generated
+251
@@ -13,10 +13,12 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"moveable": "^0.53.0",
|
||||||
"next": "16.0.2",
|
"next": "16.0.2",
|
||||||
"next-intl": "^4.5.2",
|
"next-intl": "^4.5.2",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-moveable": "^0.56.0",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -385,6 +387,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cfcs/core": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@egjs/component": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
@@ -520,6 +531,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@daybrush/utils": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@daybrush/utils/-/utils-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
@@ -587,6 +604,33 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@egjs/agent": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/agent/-/agent-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@egjs/children-differ": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@egjs/list-differ": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@egjs/component": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@egjs/list-differ": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/list-differ/-/list-differ-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
|
||||||
@@ -2449,6 +2493,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@scena/dragscroll": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scena/dragscroll/-/dragscroll-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.6.0",
|
||||||
|
"@scena/event-emitter": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scena/event-emitter": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scena/event-emitter/-/event-emitter-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scena/matrix": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scena/matrix/-/matrix-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@schummar/icu-type-parser": {
|
"node_modules/@schummar/icu-type-parser": {
|
||||||
"version": "1.21.5",
|
"version": "1.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||||
@@ -4563,6 +4635,52 @@
|
|||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/croact": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/croact/-/croact-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9GhvyzTY/IVUrMQ2iz/mzgZ8+NcjczmIo/t4FkC1CU0CEcau6v6VsEih4jkTa4ZmRgYTF0qXEZLObCzdDFplpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@egjs/list-differ": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/croact-css-styled": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/croact-css-styled/-/croact-css-styled-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-G7yvRiVJ3Eoj0ov2h2xR4312hpOzATay2dGS9clK8yJQothjH1sBXIyvOeRP5wBKD9mPcKcoUXPCPsl0tQog4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"css-styled": "~1.0.8",
|
||||||
|
"framework-utils": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/croact-moveable": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/croact-moveable/-/croact-moveable-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-fc3bieV6CdqqZFtzsSLi9KmvUMFW3oakUfhPCls1BxKjOfUfn8rktteGED2341A/Qghy8tI3Hm6SdocIc68IKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@egjs/agent": "^2.2.1",
|
||||||
|
"@egjs/children-differ": "^1.0.1",
|
||||||
|
"@egjs/list-differ": "^1.0.0",
|
||||||
|
"@scena/dragscroll": "^1.4.0",
|
||||||
|
"@scena/event-emitter": "^1.0.5",
|
||||||
|
"@scena/matrix": "^1.1.1",
|
||||||
|
"croact-css-styled": "^1.1.9",
|
||||||
|
"css-to-mat": "^1.1.1",
|
||||||
|
"framework-utils": "^1.1.0",
|
||||||
|
"gesto": "^1.19.3",
|
||||||
|
"overlap-area": "^1.1.0",
|
||||||
|
"react-css-styled": "^1.1.9",
|
||||||
|
"react-moveable": "~0.56.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"croact": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4578,6 +4696,25 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-styled": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-styled/-/css-styled-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-to-mat": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-to-mat/-/css-to-mat-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@scena/matrix": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||||
@@ -5723,6 +5860,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framework-utils": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framework-utils/-/framework-utils-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -5799,6 +5942,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gesto": {
|
||||||
|
"version": "1.19.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gesto/-/gesto-1.19.4.tgz",
|
||||||
|
"integrity": "sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@scena/event-emitter": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -7207,6 +7360,24 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/keycode": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/keycon": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycon/-/keycon-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cfcs/core": "^0.0.6",
|
||||||
|
"@daybrush/utils": "^1.7.1",
|
||||||
|
"@scena/event-emitter": "^1.0.2",
|
||||||
|
"keycode": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7703,6 +7874,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moveable": {
|
||||||
|
"version": "0.53.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/moveable/-/moveable-0.53.0.tgz",
|
||||||
|
"integrity": "sha512-71jS9zIoQzMhnNvduhg4tUEdm23+fO/40FN7muVMbZvVwbTku2MIxxLhnU4qFvxI4oVxn75l79SbtgjuA+s7Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@scena/event-emitter": "^1.0.5",
|
||||||
|
"croact": "^1.0.4",
|
||||||
|
"croact-moveable": "~0.9.0",
|
||||||
|
"react-moveable": "~0.56.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8065,6 +8249,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/overlap-area": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -8392,6 +8585,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-css-styled": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-css-styled/-/react-css-styled-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-styled": "~1.0.8",
|
||||||
|
"framework-utils": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
@@ -8411,6 +8614,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-moveable": {
|
||||||
|
"version": "0.56.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-moveable/-/react-moveable-0.56.0.tgz",
|
||||||
|
"integrity": "sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@egjs/agent": "^2.2.1",
|
||||||
|
"@egjs/children-differ": "^1.0.1",
|
||||||
|
"@egjs/list-differ": "^1.0.0",
|
||||||
|
"@scena/dragscroll": "^1.4.0",
|
||||||
|
"@scena/event-emitter": "^1.0.5",
|
||||||
|
"@scena/matrix": "^1.1.1",
|
||||||
|
"css-to-mat": "^1.1.1",
|
||||||
|
"framework-utils": "^1.1.0",
|
||||||
|
"gesto": "^1.19.3",
|
||||||
|
"overlap-area": "^1.1.0",
|
||||||
|
"react-css-styled": "^1.1.9",
|
||||||
|
"react-selecto": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-selecto": {
|
||||||
|
"version": "1.26.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-selecto/-/react-selecto-1.26.3.tgz",
|
||||||
|
"integrity": "sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"selecto": "~1.26.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
@@ -8705,6 +8938,24 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/selecto": {
|
||||||
|
"version": "1.26.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/selecto/-/selecto-1.26.3.tgz",
|
||||||
|
"integrity": "sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@daybrush/utils": "^1.13.0",
|
||||||
|
"@egjs/children-differ": "^1.0.1",
|
||||||
|
"@scena/dragscroll": "^1.4.0",
|
||||||
|
"@scena/event-emitter": "^1.0.5",
|
||||||
|
"css-styled": "^1.0.8",
|
||||||
|
"css-to-mat": "^1.1.1",
|
||||||
|
"framework-utils": "^1.1.0",
|
||||||
|
"gesto": "^1.19.4",
|
||||||
|
"keycon": "^1.2.0",
|
||||||
|
"overlap-area": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|||||||
@@ -20,10 +20,12 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"moveable": "^0.53.0",
|
||||||
"next": "16.0.2",
|
"next": "16.0.2",
|
||||||
"next-intl": "^4.5.2",
|
"next-intl": "^4.5.2",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-moveable": "^0.56.0",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user