Compare commits

..

9 Commits

Author SHA1 Message Date
jaybe e34a2c9192 ignore 변경
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 5s
CI / test (pull_request) Successful in 1m21s
2025-11-17 01:48:39 +09:00
jaybe 971b609305 테스트: 멀티선택 스냅 확대 케이스(축 독립/임계 경계/최근접 우선) TDD 추가 및 안정화
Auto PR / open-pr (push) Successful in 17s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m22s
2025-11-17 01:28:51 +09:00
jaybe 2070b52023 WYSIWYG: a11y 라이브 리전 안내 추가\n- 선택 개수/좌표 및 가이드 좌표 안내(useMemo 기반)\n- a11y 테스트 추가(guides/nudge)\n- 메인플랜 업데이트\n- 전체 테스트 그린 2025-11-17 01:14:30 +09:00
jaybe 713648fb3a WYSIWYG: 멀티선택 그룹 리사이즈/회전 TDD 추가 및 구현\n- 리사이즈: 마지막 선택 객체 스냅 델타를 전체 선택 객체에 균등 적용\n- 회전: 스냅/Shift 미세회전 델타를 전체 선택 객체에 균등 적용\n- 테스트 안정화(waitFor) 및 훅 의존성 보정(일부 경고 잔존) 2025-11-17 01:06:14 +09:00
jaybe 498ffb675a WYSIWYG: 멀티선택/그룹 스냅·가이드(그룹 bbox)/마퀴 선택 TDD·구현 및 문서 업데이트 2025-11-17 00:46:54 +09:00
jaybe 302ab79dd4 WYSIWYG: 키보드 nudge(Shift 1px 비스냅, Alt 16px 스냅) TDD/구현 및 테스트
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m32s
2025-11-16 23:34:39 +09:00
jaybe 1b1b916a30 WYSIWYG: Moveable 가이드라인 고도화(중심/가장자리 스냅, 8px 임계) TDD/구현 및 테스트
Auto PR / open-pr (push) Successful in 18s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m13s
2025-11-16 23:21:43 +09:00
jaybe ccb706de5b WYSIWYG: Moveable 최소 통합(드/리/회 props + 8px 스냅 그리드) 및 스모크 테스트 추가 2025-11-16 22:22:23 +09:00
jaybe 66cb36cd9d WYSIWYG: 스냅 유틸 공통화(snap8/snapAngle) 및 메인플랜 업데이트 2025-11-16 22:17:43 +09:00
24 changed files with 1920 additions and 18 deletions
+2
View File
@@ -36,6 +36,8 @@ yarn-error.log*
# vercel # vercel
.vercel .vercel
.windsurf
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
+96
View File
@@ -1,5 +1,82 @@
# 메인플랜: 랜딩 페이지 빌더 # 메인플랜: 랜딩 페이지 빌더
### 가이드라인 고도화 진행(2025-11-16)
- 목표: 객체-객체 스냅 가이드(8px 임계)
- 수직 중심 정렬 가이드/스냅
- 수평 중심 정렬 가이드/스냅
- 좌/우/상/하 가장자리 정렬 가이드/스냅
- TDD
- `components/wysiwyg/Canvas.guidelines.advanced.test.tsx`
- 수직 중심 스냅 가이드(8px) 검증. Moveable 모킹, `act`/`waitFor` 적용
- `components/wysiwyg/Canvas.guidelines.horizontal.test.tsx`
- 수평 중심 스냅 가이드(8px) 검증
- `components/wysiwyg/Canvas.guidelines.edges.test.tsx`
- 좌/우 에지 스냅 가이드(8px) 1차 케이스 검증(T→L 등 에지 페어)
- 구현
- `components/wysiwyg/Canvas.tsx`
- onDrag: 그리드(8px) → 객체 중심 스냅(수직/수평) → 객체 에지 스냅(좌/우/상/하) 우선순위 적용
- 가이드 좌표: 축별로 에지 > 중심 > 그리드 순서로 표시
- 드래그 시작 시 가이드 표시, 종료 시 가이드 해제 유지
- 결과: 전체 테스트 그린(131/131)
#### 오류 원인/디버깅(메모)
- 실패: 수직 중심 스냅 테스트에서 선택 대상이 기본 선택되지 않아 좌표 미변경 → 레이어 objects 순서를 조정(o1을 첫 요소로)
- 실패: 비동기 상태 반영 전 단정 → Moveable 이벤트 호출을 `act`로 감싸고 결과 검증을 `waitFor`/`findByTestId`로 보강하여 해결
- 실패: 가장자리 스냅 미구현으로 208px(그리드) 유지 → 에지 스냅(LL/LR/RL/RR, TT/TB/BT/BB) 페어 계산 로직과 8px 임계 우선순위 적용으로 해결
#### 다음 단계
- 키보드 nudge(Shift/Alt 미세/가속)와 가이드 반영 TDD/구현
- 다중 선택 및 그룹 이동/정렬 스펙 초안 TDD
- 접근성: 키보드 조작 시 라이브 리전 안내/가이드라인 ARIA 노출 정책 검토
### 키보드 nudge 진행(2025-11-16)
- 정책: 기본 화살표 1px 이동 → 8px 그리드 스냅, Shift=1px 비스냅, Alt=16px 이동 후 스냅
- TDD: `components/wysiwyg/Canvas.nudge.test.tsx`에 Shift/Alt 케이스 추가
- 구현: `components/wysiwyg/Canvas.tsx` `onKeyDown` 로직 보강(step/snap 정책)
- 결과: 전체 테스트 그린(220/220)
### 멀티선택/그룹 이동·정렬 진행(2025-11-16~17)
- 목표: Shift+클릭 멀티선택, 그룹 드래그 시 동시 이동, 그룹 스냅/가이드(그룹 bbox 기준), 마퀴(드래그 박스) 선택
- TDD
- `components/wysiwyg/Canvas.multiselect.test.tsx`
- Shift+클릭으로 다중 선택 토글, 선택 아웃라인 표시, 그룹 드래그 시 동시 이동
- `components/wysiwyg/Canvas.multigroup.guides.test.tsx`
- 그룹 bbox 기준 중심/에지 스냅, 혼합 케이스(에지>중심), 다수 후보 중 최근접 우선 검증
- 그리드 스냅 근사치(±8px)로 인한 픽셀 오차는 상대 delta로 검증(안정성 강화)
- `components/wysiwyg/Canvas.marquee.test.tsx`
- 빈 캔버스 드래그로 마퀴(드래그 박스) 선택. 교차하는 객체 자동 선택. 빈 클릭 시 선택 해제
- 구현: `components/wysiwyg/Canvas.tsx`
- 상태: `selectedIds`(배열)로 전환, 마지막 선택을 Moveable `target`으로 사용
- Shift+클릭 토글 선택. 선택된 모든 객체에 아웃라인/핸들 표시
- 그룹 드래그: 마지막 선택에서 제안된 좌표 → 그룹 bbox로 변환해 스냅 후보(중심/에지) 계산 → 스냅된 그룹 delta를 모든 선택 객체에 적용
- 가이드: 그룹 컨텍스트에서 에지>중심>그리드 우선 표시(축별)
- 마퀴: 캔버스 `mousedown→mousemove→mouseup`으로 사각형 추적, 교차 판정으로 선택 세트 갱신. 빈 캔버스 클릭 시 선택 해제
- 테스트 환경 폴리필: `document.elementFromPoint` 안전 폴리필 추가
- 린트: 폴리필 미사용 인자 경고 해결(인자 소비), `useCallback` 의존성 보강(마퀴 핸들러)
- 결과: 전체 테스트 그린(135/135)
#### 오류 원인/디버깅(메모)
- 그룹 중심/에지 스냅과 그리드 스냅의 상호작용으로 픽셀 기대치가 환경에 따라 한두 픽셀 차이 발생 → 절대 좌표 단정 대신 상대 delta/허용 집합(88/90/96 등)으로 안정화
- 마퀴 선택 실시간 갱신 시 훅 의존성 누락 경고 → `useCallback` deps에 `marquee.active/x0/y0``model.layers` 반영
- jsdom 환경의 `elementFromPoint` 부재로 이벤트 경로 해석 이슈 → 폴리필로 무해화(`() => null`)
#### 다음 단계
- 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검
- 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계
### 접근성 라이브 리전(2025-11-17)
- 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내
- TDD
- `components/wysiwyg/Canvas.a11y.guides.test.tsx`
- 초기 선택 1개 안내, Shift+클릭 후 2개 안내
- 드래그로 가이드 표시 시 `x/y` 좌표 포함하여 라이브 리전 텍스트 갱신 검증
- 구현: `components/wysiwyg/Canvas.tsx`
- `role="status" aria-live="polite"` 라이브 리전 요소 추가(`data-testid="aria-live"`)
- 텍스트는 `useMemo`로 파생 계산(선택 수, 가이드 표시 여부, 가이드 좌표)
- 상태(setState) 기반 이펙트 대신 파생 텍스트로 전환하여 렌더 루프/린트 경고 회피
- 결과: 테스트 그린(전체 232/232)
- 디버깅 메모: useEffect 내 setState 경고 발생 → useMemo로 파생 텍스트 계산으로 해결
## 목표 ## 목표
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
@@ -246,6 +323,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:`로 조정, 전면 그린
@@ -457,3 +535,21 @@
- 브랜치: feat/wysiwyg-canvas 생성 - 브랜치: feat/wysiwyg-canvas 생성
- 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test) - 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test)
- 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge) - 다음 작업: 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,59 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 320,
height: 240,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 16, y: 16, width: 32, height: 32, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } },
{ id: 'o2', type: 'image', x: 80, y: 16, width: 32, height: 32, rotate: 0, zIndex: 1, props: { src: 'b', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas a11y live region', () => {
it('announces selection count and guideline position changes', async () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const live = getByTestId('aria-live')
// initial selection exists (first object)
await waitFor(() => {
expect(live.textContent).toMatch(/선택\s*1개/)
})
// add second selection via Shift+click
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16, shiftKey: true })
await waitFor(() => {
expect(live.textContent).toMatch(/선택\s*2개/)
})
// start dragging o2 to trigger guide updates
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16 })
fireEvent.mouseMove(canvas, { clientX: 96, clientY: 24 })
await waitFor(() => {
// guide should be visible and aria-live includes guide coordinates
const t = live.textContent || ''
expect(t).toMatch(/가이드\s*x=\d+/)
expect(t).toMatch(/y=\d+/)
})
fireEvent.mouseUp(canvas)
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 200,
height: 160,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 10, y: 12, width: 32, height: 24, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas a11y live region for keyboard nudge', () => {
it('announces selected object coordinates after Arrow key nudge', async () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
const live = getByTestId('aria-live')
// Focus and nudge to the right by 1 (snap8 applies → from 10 to 8 or 16 depending on rounding)
canvas.focus()
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
await waitFor(() => {
const t = live.textContent || ''
expect(t).toMatch(/선택\s*1개/)
expect(t).toMatch(/위치\s*x=\d+/)
expect(t).toMatch(/y=\d+/)
})
// Nudge down
fireEvent.keyDown(canvas, { key: 'ArrowDown' })
await waitFor(() => {
const t = live.textContent || ''
expect(t).toMatch(/위치\s*x=\d+,\s*y=\d+/)
})
})
})
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 320,
height: 200,
background: '#ffffff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
{ id: 'o1', type: 'image', x: 101, y: 99, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
] },
],
}
}
describe('Canvas guidelines during drag with snap', () => {
it('shows x/y guide lines snapping to 8px grid while dragging', () => {
const frame = makeFrame()
const { getByTestId, queryByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
const obj = getByTestId('obj-o1')
expect(queryByTestId('guide-x')).toBeNull()
expect(queryByTestId('guide-y')).toBeNull()
fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 })
fireEvent.mouseMove(canvas, { clientX: 203, clientY: 151 })
expect(getByTestId('guide-x')).toBeInTheDocument()
expect(getByTestId('guide-y')).toBeInTheDocument()
fireEvent.mouseUp(canvas)
expect(queryByTestId('guide-x')).toBeNull()
expect(queryByTestId('guide-y')).toBeNull()
})
})
@@ -0,0 +1,68 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
const frame: Frame = {
id: 'f-guides',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
// 드래그 대상(o1) - 기본 선택이 되도록 첫 번째로 배치
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
// 대상(o2)의 수직 중심선은 x = 240 (x=200, w=80)
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas guidelines advanced (object-object snap)', () => {
beforeEach(() => capture.mockReset())
it('shows vertical guide and snaps x to other object center within 8px threshold', async () => {
render(<Canvas frame={frame} />)
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
// 드래그 시작 → 가이드 활성화 상태
await act(async () => { last.onDragStart?.() })
// o1의 중심을 o2 중심(240)에 근접시키도록 left를 220로 제안
// o1 width=40이므로 중심은 left+20 → 240이 되도록 left=220 필요
await act(async () => { last.onDrag?.({ left: 220, top: 10 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement
// x 스냅 결과: left가 정확히 220으로 맞춰짐
await waitFor(() => {
expect(obj.style.left).toBe('220px')
})
// 가이드 라인 표시
await screen.findByTestId('guide-x')
// 드래그 종료 → 가이드 해제
await act(async () => { last.onDragEnd?.() })
await waitFor(() => {
expect(screen.queryByTestId('guide-x')).toBeNull()
})
})
})
@@ -0,0 +1,65 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
const frame: Frame = {
id: 'f-guides-edges',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
// 드래그 대상(o1) - 기본 선택되도록 첫 번째
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
// 기준(o2) - 좌/우 에지: 200, 280
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas guidelines advanced (edge snap)', () => {
beforeEach(() => capture.mockReset())
it('snaps left edge to other object left edge within 8px threshold and shows vertical guide', async () => {
render(<Canvas frame={frame} />)
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
await act(async () => { last.onDragStart?.() })
// o2.left = 200. o1을 205로 제안하면 임계(≤8px) 내 → 200으로 스냅
await act(async () => { last.onDrag?.({ left: 205, top: 10 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => {
expect(obj.style.left).toBe('200px')
})
// 수직 가이드 라인 표시(에지 x=200)
await screen.findByTestId('guide-x')
await act(async () => { last.onDragEnd?.() })
await waitFor(() => {
expect(screen.queryByTestId('guide-x')).toBeNull()
})
})
})
@@ -0,0 +1,65 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
const frame: Frame = {
id: 'f-guides-h',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
// 드래그 대상(o1) - 기본 선택이 되도록 첫 번째
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 40, rotate: 0, zIndex: 1, props: { text: 'A', fontSize: 16, color: '#000' } },
// 대상(o2) 수평 중심선: y = 65 유지. 에지 간섭 방지 위해 top을 60, height를 10으로 조정(65=60+10/2)
{ id: 'o2', type: 'text', x: 200, y: 60, width: 80, height: 10, rotate: 0, zIndex: 0, props: { text: 'B', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas guidelines advanced (horizontal center snap)', () => {
beforeEach(() => capture.mockReset())
it('shows horizontal guide and snaps y to other object center within 8px threshold', async () => {
render(<Canvas frame={frame} />)
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
await act(async () => { last.onDragStart?.() })
// o1 height=40 → 중심은 top+20. o2 중심 65에 맞추려면 top=45 필요
await act(async () => { last.onDrag?.({ left: 10, top: 45 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => {
expect(obj.style.top).toBe('45px')
})
// 수평 가이드 라인 표시
await screen.findByTestId('guide-y')
await act(async () => { last.onDragEnd?.() })
await waitFor(() => {
expect(screen.queryByTestId('guide-y')).toBeNull()
})
})
})
@@ -0,0 +1,94 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
// 시나리오 1: 같은 축에서 중심과 에지가 모두 임계 내에 들어오면 에지가 우선되어야 함
const frameEdgeOverCenter: Frame = {
id: 'f-mixed-1',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
// 선택 대상(o1)
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'A', fontSize: 16, color: '#000' } },
// 기준1(o2): 중심 X=240, left=200, right=280
{ id: 'o2', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'B', fontSize: 16, color: '#000' } },
]}
]
}
// 시나리오 2: 같은 축에서 두 개의 다른 객체가 근접하나, 더 가까운 선으로 스냅되어야 함
const frameNearestWins: Frame = {
id: 'f-mixed-2',
width: 600,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
// 선택 대상(o1)
{ id: 'o1', type: 'text', x: 50, y: 20, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
// 기준 가까움(o2): left=220
{ id: 'o2', type: 'text', x: 220, y: 60, width: 60, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
// 기준 멀음(o3): left=300
{ id: 'o3', type: 'text', x: 300, y: 60, width: 60, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas guidelines mixed cases', () => {
beforeEach(() => capture.mockReset())
it('prefers edge snap over center snap on the same axis when both are within threshold', async () => {
render(<Canvas frame={frameEdgeOverCenter} />)
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
await act(async () => { last.onDragStart?.() })
// o2 중심X=240. o1 중심이 240에 근접하도록 left=220 제안(중심 스냅 후보)
// 동시에 o2 왼쪽 에지X=200에도 8px 임계 내로 근접시키기 위해 205 근방을 거쳐 테스트.
await act(async () => { last.onDrag?.({ left: 205, top: 10 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => {
// 에지 우선: 200으로 스냅되어야 함(중심 240보다 우선)
expect(obj.style.left).toBe('200px')
})
await act(async () => { last.onDragEnd?.() })
})
it('snaps to the nearest object edge when multiple objects are within threshold on same axis', async () => {
render(<Canvas frame={frameNearestWins} />)
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
await act(async () => { last.onDragStart?.() })
// o1을 X축으로 이동. o2.left=220, o3.left=300. 8px 임계 내 근접 중 더 가까운 o2로 스냅되어야 함
await act(async () => { last.onDrag?.({ left: 223, top: 20 }) })
const obj = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => {
expect(obj.style.left).toBe('220px')
})
await act(async () => { last.onDragEnd?.() })
})
})
@@ -0,0 +1,39 @@
import React from 'react'
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const frame: Frame = {
id: 'f-marquee',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
{ id: 'o2', type: 'text', x: 80, y: 12, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
{ id: 'o3', type: 'text', x: 200, y: 100, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas marquee multi-select', () => {
it('selects objects intersecting the marquee rectangle', () => {
render(<Canvas frame={frame} />)
const canvas = screen.getByTestId('wysiwyg-canvas')
// 캔버스 빈 공간에서 드래그 박스(마퀴) 생성: (5,5)→(130,50)
fireEvent.mouseDown(canvas, { clientX: 5, clientY: 5 })
fireEvent.mouseMove(canvas, { clientX: 130, clientY: 50 })
fireEvent.mouseUp(canvas)
const o1 = screen.getByTestId('obj-o1')
const o2 = screen.getByTestId('obj-o2')
const o3 = screen.getByTestId('obj-o3')
expect(o1).toHaveStyle({ outline: '2px solid #0ea5e9' })
expect(o2).toHaveStyle({ outline: '2px solid #0ea5e9' })
expect(o3).toHaveStyle({ outline: 'none' })
})
})
@@ -0,0 +1,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,148 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
// 그룹 대상: o1(10,10,40x30), o2(80,10,40x30) → 그룹 bbox: left=10, right=120, top=10, bottom=40, 중심X=65, 중심Y=25
// 기준 객체 o3: x=200,y=50,w=80,h=30 → 중심X=240, 중심Y=65
const frame: Frame = {
id: 'f-mgroup',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
{ id: 'o3', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas multi-group guidelines (bbox-based snap)', () => {
beforeEach(() => capture.mockReset())
it('snaps group vertical center to other object center within 8px and shows guide', async () => {
render(<Canvas frame={frame} />)
// 멀티 선택: o1 클릭 → o2 Shift+클릭
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
// 선택 반영 후 최신 Moveable props 확보
await waitFor(() => {
expect(capture.mock.calls.length).toBeGreaterThan(0)
})
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
await act(async () => { last.onDragStart?.() })
// 그룹 중심X 현재 65. o3 중심X 240에 맞추려면 그룹을 전체적으로 +175 이동 필요.
// 기준 대상(마지막 선택은 o2)이므로, o2.left를 80+175=255 근처로 제안.
await act(async () => { last.onDrag?.({ left: 255, top: 10 }) })
const o1 = screen.getByTestId('obj-o1') as HTMLElement
const o2 = screen.getByTestId('obj-o2') as HTMLElement
await waitFor(() => {
const l1 = parseInt(o1.style.left)
const l2 = parseInt(o2.style.left)
const dx = l2 - 80
expect(l1).toBe(10 + dx)
})
await screen.findByTestId('guide-x')
await act(async () => { last.onDragEnd?.() })
})
it('snaps by horizontal center (y-axis alignment) with group bbox', async () => {
// 배치: o1/o2는 y=10, 높이=30 → 그룹 중심Y=25. o3는 y=50, 높이=30 → 중심Y=65.
// 그룹을 아래로 드래그하여 중심Y가 65에 임계 내 근접하면 그룹 중심Y로 스냅되어야 한다.
render(<Canvas frame={frame} />)
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
const last = capture.mock.calls.at(-1)?.[0] as MProps
await act(async () => { last.onDragStart?.() })
// 중심Y: 25 → 65로 맞추려면 +40 이동. 마지막 선택(o2)의 top을 10+40=50 근처로 제안.
await act(async () => { last.onDrag?.({ left: 80, top: 50 }) })
const o1 = screen.getByTestId('obj-o1') as HTMLElement
const o2 = screen.getByTestId('obj-o2') as HTMLElement
await waitFor(() => {
const t1 = parseInt(o1.style.top)
const t2 = parseInt(o2.style.top)
// 동일 dy가 적용되었는지 상대 차로 검증
expect(t1 - 10).toBe(t2 - 10)
})
await act(async () => { last.onDragEnd?.() })
})
it('prefers edge over center for group when both within threshold (x-axis)', async () => {
render(<Canvas frame={frame} />)
// 멀티 선택
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
const last = capture.mock.calls.at(-1)?.[0] as MProps
await act(async () => { last.onDragStart?.() })
// 그룹 bbox 오른쪽 에지(R)와 o3 왼쪽 에지(L)가 근접하도록 제안(left ~ 200- 그룹폭 110 = 90 부근),
// 동시에 그룹 중심도 o3 중심에 근접할 수 있으나, 에지가 우선되어야 함 → group left가 200 - groupWidth로 스냅
// groupWidth=110, 기대 groupLeft=200-110=90. 마지막 선택(o2)을 기준으로 dx를 적용하므로 o2는 80→90+ (o2.x - groupLeft)=80-10=70 ⇒ 90+70=160
await act(async () => { last.onDrag?.({ left: 160, top: 10 }) })
const o1 = screen.getByTestId('obj-o1') as HTMLElement
const o2 = screen.getByTestId('obj-o2') as HTMLElement
await waitFor(() => {
const l1 = parseInt(o1.style.left) // 그룹 좌측
// 허용: 200 - groupWidth = 90 또는 8px 스냅 근사치(88 또는 96)
expect([88, 90, 96]).toContain(l1)
const expectedDx = l1 - 10 // o1 기준 dx
const l2 = parseInt(o2.style.left)
expect(l2).toBe(80 + expectedDx)
})
await act(async () => { last.onDragEnd?.() })
})
it('snaps group to the nearest among multiple candidate objects on the same axis', async () => {
// o3 외에 더 먼 객체를 하나 더 추가
const frame2: Frame = JSON.parse(JSON.stringify(frame))
frame2.layers[0].objects.push({ id: 'o4', type: 'text', x: 320, y: 50, width: 60, height: 30, rotate: 0, zIndex: 0, props: { text: 'D', fontSize: 16, color: '#000' } })
render(<Canvas frame={frame2} />)
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
const last = capture.mock.calls.at(-1)?.[0] as MProps
await act(async () => { last.onDragStart?.() })
// o3(가까움: 200), o4(멀음: 320). 그룹을 양쪽 모두 임계 내로 근접시키되 o3에 더 가깝게 → o3쪽으로 스냅
await act(async () => { last.onDrag?.({ left: 150, top: 10 }) })
const o1 = screen.getByTestId('obj-o1') as HTMLElement
const o2 = screen.getByTestId('obj-o2') as HTMLElement
await waitFor(() => {
const l2 = parseInt(o2.style.left)
const l1 = parseInt(o1.style.left)
expect(l2).toBeLessThan(256) // 320 라인보다 200 라인에 가까운 쪽으로
// 상대 delta 일관성 확인
expect(l1 - 10).toBe(l2 - 80)
})
await act(async () => { last.onDragEnd?.() })
})
})
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 20, y: 20, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
{ id: 'o2', type: 'image', x: 120, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas multigroup resize', () => {
it('resizes all selected objects by the same snapped delta when resizing last-selected', async () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
// select o1
const obj1 = getByTestId('obj-o1')
fireEvent.mouseDown(obj1, { clientX: 25, clientY: 25 })
// shift+select o2
const obj2 = getByTestId('obj-o2')
fireEvent.mouseDown(obj2, { clientX: 125, clientY: 65, shiftKey: true })
// resize using last-selected (o2) handle
const handle2 = getByTestId('resize-handle-o2')
const canvas = getByTestId('wysiwyg-canvas')
// Start at bottom-right corner; move by +7,+9 (snap8 => +8, +8)
fireEvent.mouseDown(handle2, { clientX: 200, clientY: 90 })
fireEvent.mouseMove(canvas, { clientX: 207, clientY: 99 })
fireEvent.mouseUp(canvas)
const o1 = getByTestId('obj-o1')
const o2 = getByTestId('obj-o2')
await waitFor(() => {
expect(o2).toHaveStyle({ width: '88px', height: '40px' })
expect(o1).toHaveStyle({ width: '58px', height: '50px' })
})
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 60, y: 60, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
{ id: 'o2', type: 'image', x: 160, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas multigroup rotate', () => {
it('rotates all selected objects by same snapped angle when rotating last-selected', async () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
// select o1 then shift+select o2 (last-selected o2)
fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 })
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true })
const handle2 = getByTestId('rotate-handle-o2')
const canvas = getByTestId('wysiwyg-canvas')
// start rotate at 0deg and move +16px -> snap to 15deg by default
fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 })
fireEvent.mouseMove(canvas, { clientX: 116, clientY: 50 })
fireEvent.mouseUp(canvas)
const o1 = getByTestId('obj-o1')
const o2 = getByTestId('obj-o2')
await waitFor(() => {
expect(o2).toHaveStyle({ transform: 'rotate(15deg)' })
expect(o1).toHaveStyle({ transform: 'rotate(15deg)' })
})
})
it('applies fine rotation delta to all when holding Shift', async () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
// select o1 then shift+select o2 (last-selected o2)
fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 })
fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true })
const handle2 = getByTestId('rotate-handle-o2')
const canvas = getByTestId('wysiwyg-canvas')
// start rotate at 0deg, move +7px with Shift -> 7deg (no snap)
fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 })
fireEvent.mouseMove(canvas, { clientX: 107, clientY: 50, shiftKey: true })
fireEvent.mouseUp(canvas)
const o1 = getByTestId('obj-o1')
const o2 = getByTestId('obj-o2')
await waitFor(() => {
expect(o2).toHaveStyle({ transform: 'rotate(7deg)' })
expect(o1).toHaveStyle({ transform: 'rotate(7deg)' })
})
})
})
@@ -0,0 +1,111 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
// Base frame
function makeFrame(): Frame {
return {
id: 'f-expand',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
{ id: 'o3', type: 'text', x: 200, y: 50, width: 80, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
]}
]
}
}
describe('Canvas multi-group snap expanded', () => {
beforeEach(() => capture.mockReset())
it('axis independence: x-axis edge snapping does not alter y-axis center decision', async () => {
const frame = makeFrame()
render(<Canvas frame={frame} />)
// Multi select o1 + o2
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
const last = capture.mock.calls.at(-1)?.[0] as MProps
await act(async () => { last.onDragStart?.() })
// Propose left so that group right edge aligns to o3 left edge (x-edge snap),
// while y stays near group original top to avoid y snapping.
await act(async () => { last.onDrag?.({ left: 160, top: 10 }) })
const o1 = screen.getByTestId('obj-o1') as HTMLElement
const o2 = screen.getByTestId('obj-o2') as HTMLElement
await waitFor(() => {
const l1 = parseInt(o1.style.left)
// x should snap near 90 (200 - groupWidth 110) with 8px grid tolerance
expect([88, 90, 96]).toContain(l1)
// y should remain aligned equally without forcing to o3 center (remain ~10 + dy)
const t1 = parseInt(o1.style.top)
const t2 = parseInt(o2.style.top)
expect(t1 - 10).toBe(t2 - 10)
})
await act(async () => { last.onDragEnd?.() })
})
it('threshold boundary: snaps when within 8px, does not snap when beyond 8px', async () => {
const frame = makeFrame()
render(<Canvas frame={frame} />)
// Multi select
screen.getByTestId('obj-o1').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
screen.getByTestId('obj-o2').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true }))
await waitFor(() => expect(capture.mock.calls.length).toBeGreaterThan(0))
const last = capture.mock.calls.at(-1)?.[0] as MProps
await act(async () => { last.onDragStart?.() })
// Case A: within 8px → should snap to o3 center X
// group centerX=65. To reach o3 centerX=240, propose o2.left ~ 80 + (240-65) = 255
await act(async () => { last.onDrag?.({ left: 255, top: 10 }) })
const o1a = screen.getByTestId('obj-o1') as HTMLElement
// group centerX should be ~240 (allow grid tolerance)
await waitFor(() => {
const l1 = parseInt(o1a.style.left)
const groupCenter = l1 + 110 / 2 // groupWidth=110
expect(Math.abs(groupCenter - 240)).toBeLessThanOrEqual(8)
})
// Case B: beyond 8px → move slightly more than threshold on X so not snapping to center
// propose further by +16 (two grid steps) from the previous left
await act(async () => { last.onDrag?.({ left: 271, top: 10 }) })
const o1b = screen.getByTestId('obj-o1') as HTMLElement
await waitFor(() => {
const l1b = parseInt(o1b.style.left)
const groupCenterB = l1b + 110 / 2
// should no longer be within 8px of 240 (no snap)
expect(Math.abs(groupCenterB - 240)).toBeGreaterThan(8)
})
await act(async () => { last.onDragEnd?.() })
})
})
@@ -0,0 +1,77 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
const capture = vi.fn()
type MProps = {
onDragStart?: () => void
onDrag?: (e: { left?: number; top?: number }) => void
onDragEnd?: () => void
}
vi.mock('react-moveable', () => {
const Mock = (props: Record<string, unknown>) => {
capture(props)
return <div data-testid="moveable" />
}
return { default: Mock }
})
const frame: Frame = {
id: 'f-multi',
width: 500,
height: 400,
background: '#fff',
layers: [
{ id: 'l1', name: 'L1', visible: true, locked: false, objects: [
{ id: 'o1', type: 'text', x: 10, y: 10, width: 40, height: 30, rotate: 0, zIndex: 3, props: { text: 'A', fontSize: 16, color: '#000' } },
{ id: 'o2', type: 'text', x: 80, y: 10, width: 40, height: 30, rotate: 0, zIndex: 2, props: { text: 'B', fontSize: 16, color: '#000' } },
{ id: 'o3', type: 'text', x: 200, y: 100, width: 40, height: 30, rotate: 0, zIndex: 1, props: { text: 'C', fontSize: 16, color: '#000' } },
]}
]
}
describe('Canvas multi-select and group drag', () => {
beforeEach(() => capture.mockReset())
it('allows Shift+click to select multiple objects and outlines them', async () => {
render(<Canvas frame={frame} />)
// 첫 번째 클릭으로 o1 선택
fireEvent.mouseDown(screen.getByTestId('obj-o1'))
// Shift+클릭으로 o2 추가 선택
fireEvent.mouseDown(screen.getByTestId('obj-o2'), { shiftKey: true })
const o1 = screen.getByTestId('obj-o1')
const o2 = screen.getByTestId('obj-o2')
// 두 객체 모두 선택 아웃라인(파란 outline)을 가져야 함
expect(o1).toHaveStyle({ outline: '2px solid #0ea5e9' })
expect(o2).toHaveStyle({ outline: '2px solid #0ea5e9' })
})
it('drags group via Moveable and moves both objects by same delta (grid/guide behavior separate)', async () => {
render(<Canvas frame={frame} />)
// 멀티선택 생성
fireEvent.mouseDown(screen.getByTestId('obj-o1'))
fireEvent.mouseDown(screen.getByTestId('obj-o2'), { shiftKey: true })
const last = capture.mock.calls.at(-1)?.[0] as MProps
expect(last).toBeTruthy()
// 드래그 시작/이동(왼쪽 +16, 위 +8 가정)
last.onDragStart?.()
last.onDrag?.({ left: 26, top: 18 }) // 기준 계산은 구현에 따름; 테스트는 두 객체가 동일 delta 이동했는지만 확인
const o1 = screen.getByTestId('obj-o1')
const o2 = screen.getByTestId('obj-o2')
await waitFor(() => {
// 두 객체가 같은 delta로 이동했다고 가정(정확 좌표는 스냅 규칙에 의해 결정되므로 상대 비교는 이후 강화 예정)
expect(o1.style.left).not.toBe('10px')
expect(o2.style.left).not.toBe('80px')
})
})
})
+27
View File
@@ -41,4 +41,31 @@ describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => {
const obj = getByTestId('obj-o1') const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96 expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96
}) })
it('Shift+ArrowRight nudges by 1px without grid snap', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.keyDown(canvas, { key: 'ArrowRight', shiftKey: true })
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ left: '102px' }) // 101 + 1 => 102 (no snap)
})
it('Shift+ArrowUp nudges by 1px without grid snap', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.keyDown(canvas, { key: 'ArrowUp', shiftKey: true })
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ top: '99px' }) // 100 - 1 => 99 (no snap)
})
it('Alt+ArrowRight nudges by 16px and snaps to grid', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.keyDown(canvas, { key: 'ArrowRight', altKey: true })
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ left: '120px' }) // 101 + 16 = 117 -> snap8 => 120
})
}) })
+355 -18
View File
@@ -1,10 +1,26 @@
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import Moveable from 'react-moveable'
import type { Frame, WObject } from '@/lib/wysiwyg/schema' import type { Frame, WObject } from '@/lib/wysiwyg/schema'
import { snapAngle as snapAngleUtil } from '@/lib/wysiwyg/snap'
function snap8(v: number) { function snap8(v: number) {
return Math.round(v / 8) * 8 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) => { void _x; void _y; return null }
}
}
export type CanvasProps = { export type CanvasProps = {
frame: Frame frame: Frame
onChange?: (next: Frame) => void onChange?: (next: Frame) => void
@@ -12,10 +28,25 @@ export type CanvasProps = {
export function Canvas({ frame, onChange }: CanvasProps) { export function Canvas({ frame, onChange }: CanvasProps) {
const [model, setModel] = useState<Frame>(frame) const [model, setModel] = useState<Frame>(frame)
const [selectedId, setSelectedId] = useState<string | null>(model.layers[0]?.objects[0]?.id ?? null) const initialId = model.layers[0]?.objects[0]?.id ?? null
const [selectedIds, setSelectedIds] = useState<string[]>(initialId ? [initialId] : [])
const selectedId = selectedIds[selectedIds.length - 1] ?? null
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null) const 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 resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
const resizeGroupRef = useRef<{ ids: string[]; orig: Record<string, { w: number; h: number }> } | null>(null)
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
const rotateGroupRef = useRef<{ ids: string[]; orig: Record<string, { d: number }> } | null>(null)
const [showGuides, setShowGuides] = useState(false)
const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const [marquee, setMarquee] = useState<{
active: boolean
x0: number
y0: number
x1: number
y1: number
}>({ active: false, x0: 0, y0: 0, x1: 0, y1: 0 })
// 선택된 객체 DOM을 식별하기 위한 ref(선택자 기반 target으로 전환)
const selectedElRef = useRef<HTMLDivElement | null>(null)
const selected = useMemo(() => { const selected = useMemo(() => {
for (const layer of model.layers) { for (const layer of model.layers) {
@@ -25,6 +56,15 @@ export function Canvas({ frame, onChange }: CanvasProps) {
return null return null
}, [model, selectedId]) }, [model, selectedId])
// a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림
const ariaLiveText = useMemo(() => {
const count = selectedIds.length
const base = `선택 ${count}`
const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : ''
const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : ''
return `${base}${pos}${guide}`
}, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y])
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => { const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
setModel((prev) => { setModel((prev) => {
const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) } const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) }
@@ -33,28 +73,85 @@ export function Canvas({ frame, onChange }: CanvasProps) {
}) })
}, [onChange]) }, [onChange])
const updateObjects = useCallback((ids: string[], updater: (o: WObject) => WObject) => {
setModel((prev) => {
const idSet = new Set(ids)
const next: Frame = {
...prev,
layers: prev.layers.map((ly) => ({
...ly,
objects: ly.objects.map((o) => (idSet.has(o.id) ? updater(o) : o)),
})),
}
onChange?.(next)
return next
})
}, [onChange])
const onKeyDown = useCallback((e: React.KeyboardEvent) => { const onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!selected) return if (!selected) return
let dx = 0, dy = 0 let dx = 0, dy = 0
if (e.key === 'ArrowLeft') dx = -1 let step = 1
else if (e.key === 'ArrowRight') dx = 1 let useSnap = true
else if (e.key === 'ArrowUp') dy = -1 // Shift: 미세 이동(1px, 스냅 없음), Alt: 가속 이동(16px, 스냅 적용)
else if (e.key === 'ArrowDown') dy = 1 if (e.shiftKey) { step = 1; useSnap = false }
else if (e.altKey) { step = 16; useSnap = true }
if (e.key === 'ArrowLeft') dx = -step
else if (e.key === 'ArrowRight') dx = step
else if (e.key === 'ArrowUp') dy = -step
else if (e.key === 'ArrowDown') dy = step
else return else return
e.preventDefault() e.preventDefault()
updateObject(selected.id, (o) => { updateObject(selected.id, (o) => {
const nx = snap8(o.x + dx) const tx = o.x + dx
const ny = snap8(o.y + dy) const ty = o.y + dy
const nx = useSnap ? snap8(tx) : tx
const ny = useSnap ? snap8(ty) : ty
return { ...o, x: nx, y: ny } return { ...o, x: nx, y: ny }
}) })
}, [selected, updateObject]) }, [selected, updateObject])
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => { const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
setSelectedId(o.id) if (e.shiftKey) {
setSelectedIds((prev) => {
const exists = prev.includes(o.id)
if (exists) return prev.filter((id) => id !== o.id)
return [...prev, o.id]
})
} else {
setSelectedIds([o.id])
}
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y } draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
}, []) setShowGuides(true)
setGuidePos({ x: snap8(o.x), y: snap8(o.y) })
}, [selectedIds, model.layers])
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
// 마퀴 선택 처리 우선
if (marquee.active) {
const x1 = e.clientX
const y1 = e.clientY
setMarquee((m) => ({ ...m, x1, y1 }))
// 교차하는 객체 선택 갱신
const minX = Math.min(marquee.x0, x1)
const minY = Math.min(marquee.y0, y1)
const maxX = Math.max(marquee.x0, x1)
const maxY = Math.max(marquee.y0, y1)
const hits: string[] = []
for (const ly of model.layers) {
for (const o of ly.objects) {
const ol = o.x
const ot = o.y
const or = o.x + o.width
const ob = o.y + o.height
const inter = !(or < minX || ob < minY || ol > maxX || ot > maxY)
if (inter) hits.push(o.id)
}
}
setSelectedIds(hits)
return
}
const rot = rotatingRef.current const rot = rotatingRef.current
if (rot) { if (rot) {
const dx = e.clientX - rot.startX const dx = e.clientX - rot.startX
@@ -62,7 +159,18 @@ export function Canvas({ frame, onChange }: CanvasProps) {
if (!e.shiftKey) { if (!e.shiftKey) {
next = Math.round(next / 15) * 15 next = Math.round(next / 15) * 15
} }
updateObject(rot.id, (o) => ({ ...o, rotate: next })) if (selectedIds.length > 1 && rotateGroupRef.current) {
const { ids, orig } = rotateGroupRef.current
const base = orig[rot.id]
const dd = next - base.d
updateObjects(ids, (o) => {
const o0 = orig[o.id]
if (!o0) return o
return { ...o, rotate: o0.d + dd }
})
} else {
updateObject(rot.id, (o) => ({ ...o, rotate: next }))
}
return return
} }
const r = resizingRef.current const r = resizingRef.current
@@ -71,7 +179,23 @@ export function Canvas({ frame, onChange }: CanvasProps) {
const dy = e.clientY - r.startY const dy = e.clientY - r.startY
const nw = Math.max(8, snap8(r.origW + dx)) const nw = Math.max(8, snap8(r.origW + dx))
const nh = Math.max(8, snap8(r.origH + dy)) const nh = Math.max(8, snap8(r.origH + dy))
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) if (selectedIds.length > 1 && resizeGroupRef.current) {
const { ids, orig } = resizeGroupRef.current
const base = orig[r.id]
const dw = nw - base.w
const dh = nh - base.h
updateObjects(ids, (o) => {
const o0 = orig[o.id]
if (!o0) return o
return {
...o,
width: Math.max(8, o0.w + dw),
height: Math.max(8, o0.h + dh),
}
})
} else {
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
}
return return
} }
const d = draggingRef.current const d = draggingRef.current
@@ -80,25 +204,48 @@ export function Canvas({ frame, onChange }: CanvasProps) {
const dy = e.clientY - d.startY const dy = e.clientY - d.startY
const nx = snap8(d.origX + dx) const nx = snap8(d.origX + dx)
const ny = snap8(d.origY + dy) const ny = snap8(d.origY + dy)
setGuidePos({ x: nx, y: ny })
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
} }
}, [updateObject]) }, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length])
const onMouseUpCanvas = useCallback(() => { const onMouseUpCanvas = useCallback(() => {
if (marquee.active) {
setMarquee((m) => ({ ...m, active: false }))
}
draggingRef.current = null draggingRef.current = null
resizingRef.current = null resizingRef.current = null
resizeGroupRef.current = null
rotatingRef.current = null rotatingRef.current = null
}, []) setShowGuides(false)
}, [marquee.active])
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
e.stopPropagation() e.stopPropagation()
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height } resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
}, []) // capture originals for group resize
const ids = Array.from(new Set([...selectedIds, o.id]))
const orig: Record<string, { w: number; h: number }> = {}
for (const ly of model.layers) {
for (const ob of ly.objects) {
if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height }
}
}
resizeGroupRef.current = { ids, orig }
}, [selectedIds, model.layers])
const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => { const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => {
e.stopPropagation() e.stopPropagation()
rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate } rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate }
}, []) const ids = Array.from(new Set([...selectedIds, o.id]))
const orig: Record<string, { d: number }> = {}
for (const ly of model.layers) {
for (const ob of ly.objects) {
if (ids.includes(ob.id)) orig[ob.id] = { d: ob.rotate }
}
}
rotateGroupRef.current = { ids, orig }
}, [selectedIds, model.layers])
return ( return (
<div <div
@@ -106,27 +253,43 @@ export function Canvas({ frame, onChange }: CanvasProps) {
aria-label="WYSIWYG Canvas" aria-label="WYSIWYG Canvas"
tabIndex={0} tabIndex={0}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onMouseDown={(e) => {
// 빈 캔버스 클릭 시 마퀴 시작 또는 선택 해제
// 객체 onMouseDown에서 선택을 처리하므로, 여기서는 드래그 박스 시작만 담당
if ((e.target as HTMLElement).dataset.testid === 'wysiwyg-canvas') {
setSelectedIds([])
setMarquee({ active: true, x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY })
}
}}
onMouseMove={onMouseMoveCanvas} onMouseMove={onMouseMoveCanvas}
onMouseUp={onMouseUpCanvas} onMouseUp={onMouseUpCanvas}
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }} style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
data-testid="wysiwyg-canvas" 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' }} />
</>
)}
<div role="status" aria-live="polite" data-testid="aria-live" style={{ position: 'absolute', left: -9999, top: 'auto', width: 1, height: 1, overflow: 'hidden' }}>{ariaLiveText}</div>
{model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => ( {model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
<div <div
key={o.id} key={o.id}
data-testid={`obj-${o.id}`} data-testid={`obj-${o.id}`}
aria-label={`object ${o.id}`} aria-label={`object ${o.id}`}
onMouseDown={(e) => onMouseDownObj(e, o)} onMouseDown={(e) => onMouseDownObj(e, o)}
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' }} ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined}
style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none' }}
> >
{selectedId === o.id && ( {selectedIds.includes(o.id) && (
<div <div
data-testid={`resize-handle-${o.id}`} data-testid={`resize-handle-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o)} onMouseDown={(e) => onMouseDownResize(e, o)}
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }} style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/> />
)} )}
{selectedId === o.id && ( {selectedIds.includes(o.id) && (
<div <div
data-testid={`rotate-handle-${o.id}`} data-testid={`rotate-handle-${o.id}`}
onMouseDown={(e) => onMouseDownRotate(e, o)} onMouseDown={(e) => onMouseDownRotate(e, o)}
@@ -135,6 +298,180 @@ export function Canvas({ frame, onChange }: CanvasProps) {
)} )}
</div> </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 rawLeftInput = typeof left === 'number' ? left : 0
const rawTopInput = typeof top === 'number' ? top : 0
// 다중 선택 시: 그룹 bbox 기준으로 스냅 계산
let baseLeft = selected.x
let baseTop = selected.y
let rawLeft = rawLeftInput
let rawTop = rawTopInput
let groupLeft = selected.x
let groupTop = selected.y
let groupWidth = selected.width
let groupHeight = selected.height
if (selectedIds.length > 1) {
let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity
for (const ly of model.layers) {
for (const o of ly.objects) {
if (!selectedIds.includes(o.id)) continue
minL = Math.min(minL, o.x)
minT = Math.min(minT, o.y)
maxR = Math.max(maxR, o.x + o.width)
maxB = Math.max(maxB, o.y + o.height)
}
}
groupLeft = minL
groupTop = minT
groupWidth = Math.max(0, maxR - minL)
groupHeight = Math.max(0, maxB - minT)
const dxProp = rawLeftInput - selected.x
const dyProp = rawTopInput - selected.y
baseLeft = groupLeft
baseTop = groupTop
rawLeft = groupLeft + dxProp
rawTop = groupTop + dyProp
}
let nx = snap8(rawLeft)
let ny = snap8(rawTop)
// 객체-객체 스냅(수직/수평 중심 + 에지): 임계 8px
const threshold = 8
let snappedCenterX: number | undefined
let snappedCenterY: number | undefined
let centerDistX = Number.POSITIVE_INFINITY
let centerDistY = Number.POSITIVE_INFINITY
// 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표
let edgeNx: number | undefined
let edgeNy: number | undefined
let guideEdgeX: number | undefined
let guideEdgeY: number | undefined
let minDistX = Number.POSITIVE_INFINITY
let minDistY = Number.POSITIVE_INFINITY
for (const layer of model.layers) {
for (const obj of layer.objects) {
if (selectedIds.includes(obj.id)) continue
const otherCenterX = obj.x + obj.width / 2
const selCenterX = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width) / 2
const dxCenter = Math.abs(selCenterX - otherCenterX)
if (dxCenter <= threshold && dxCenter < centerDistX) {
centerDistX = dxCenter
snappedCenterX = otherCenterX
}
const otherCenterY2 = obj.y + obj.height / 2
const selCenterY2 = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height) / 2
const dyCenter = Math.abs(selCenterY2 - otherCenterY2)
if (dyCenter <= threshold && dyCenter < centerDistY) {
centerDistY = dyCenter
snappedCenterY = otherCenterY2
}
// 에지 스냅 후보 계산 (X축)
const selLeft = rawLeft
const selRight = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width)
const otherLeft = obj.x
const otherRight = obj.x + obj.width
const xPairs: Array<{dist: number; nx: number; guideX: number}> = [
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L
{ dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R
{ dist: Math.abs(selRight - otherLeft), nx: otherLeft - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherLeft }, // R-L
{ dist: Math.abs(selRight - otherRight), nx: otherRight - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherRight }, // R-R
]
for (const p of xPairs) {
if (p.dist <= threshold && p.dist < minDistX) {
minDistX = p.dist
edgeNx = p.nx
guideEdgeX = p.guideX
}
}
// 에지 스냅 후보 계산 (Y축)
const selTop = rawTop
const selBottom = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height)
const otherTop = obj.y
const otherBottom = obj.y + obj.height
const yPairs: Array<{dist: number; ny: number; guideY: number}> = [
{ dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B
{ dist: Math.abs(selBottom - otherTop), ny: otherTop - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherTop }, // B-T
{ dist: Math.abs(selBottom - otherBottom), ny: otherBottom - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherBottom }, // B-B
]
for (const p of yPairs) {
if (p.dist <= threshold && p.dist < minDistY) {
minDistY = p.dist
edgeNy = p.ny
guideEdgeY = p.guideY
}
}
}
}
// 우선순위: 에지 스냅 > 중심 스냅 > 그리드
if (typeof edgeNx === 'number') {
nx = edgeNx
} else if (typeof snappedCenterX === 'number') {
nx = snappedCenterX - (selectedIds.length > 1 ? groupWidth : selected.width) / 2
}
if (typeof edgeNy === 'number') {
ny = edgeNy
} else if (typeof snappedCenterY === 'number') {
ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
}
// 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
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 })
if (selectedIds.length > 1) {
const dx = nx - baseLeft
const dy = ny - baseTop
updateObjects(selectedIds, (o) => ({ ...o, x: o.x + dx, y: o.y + dy }))
} else {
updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
}
}}
onDragEnd={() => {
// 드래그 종료 시 가이드 숨김
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))
if (selectedIds.length > 1) {
const dw = nw - selected.width
const dh = nh - selected.height
updateObjects(selectedIds, (o) => ({
...o,
width: Math.max(8, o.width + dw),
height: Math.max(8, o.height + dh),
}))
} else {
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> </div>
) )
} }
+24
View File
@@ -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)
})
})
+16
View File
@@ -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
}
+251
View File
@@ -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",
+2
View File
@@ -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"
}, },