diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 93167d59..8c17b6d0 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -152,6 +152,48 @@ const positions = await dmn.keys.getPositions(); console.log(positions['4key']); ``` +Style-related fields on each `KeyPosition` include solid colors and optional +gradient siblings: + +```typescript +interface KeyPosition { + // ...position, image, note, and counter fields... + backgroundColor?: string; + activeBackgroundColor?: string; + borderColor?: string; + activeBorderColor?: string; + borderWidth?: number; // px, defaults to 1 when unset — 0 disables the border + backgroundGradient?: GradientSpec | null; + activeBackgroundGradient?: GradientSpec | null; + borderGradient?: GradientSpec | null; + activeBorderGradient?: GradientSpec | null; + shadow?: ElementShadowSpec; + activeShadow?: ElementShadowSpec; +} + +interface GradientSpec { + angle: number; // 0 (inclusive) to 360 (exclusive) — 360 normalizes to 0; CSS semantics (0 = up, clockwise) + stops: { color: string; pos: number }[]; // 2–8 stops, pos 0–1 ascending +} + +interface ElementShadowSpec { + enabled: boolean; + color: string; // alpha controls opacity + offsetX: number; // px, -100 to 100 + offsetY: number; // px, -100 to 100 + blur: number; // px, 0 to 100 +} +``` + +When a gradient field is present it takes priority over the matching solid +field, and the solid field is kept in sync with the first stop color on save. +To return to a solid color, set the gradient field to `null` and update the +solid field. + +`shadow` and `activeShadow` control the idle and pressed shadows independently. +When omitted, the built-in shadow remains unchanged. Store a spec with +`enabled: false` to explicitly disable a shadow. + ### `dmn.keys.updatePositions(positions: KeyPositions): Promise` Replaces the key positions and returns the committed value. diff --git a/docs/content/en/api-reference/knobs/page.mdx b/docs/content/en/api-reference/knobs/page.mdx index 5baeda52..bf475b67 100644 --- a/docs/content/en/api-reference/knobs/page.mdx +++ b/docs/content/en/api-reference/knobs/page.mdx @@ -54,11 +54,17 @@ Commonly used inherited styling fields: - `backgroundColor` / `activeBackgroundColor` — idle / turning fill color - `borderColor` / `activeBorderColor` — idle / turning border color -- `borderWidth` — border thickness in px (default 3) +- `borderWidth` — border thickness in px (defaults to 1, 0 disables the border) +- `backgroundGradient` / `activeBackgroundGradient` — background gradient (GradientSpec, takes priority over the solid color) +- `borderGradient` / `activeBorderGradient` — border gradient (GradientSpec) +- `shadow` / `activeShadow` — idle / turning shadow (ElementShadowSpec; color alpha controls opacity) - `borderRadius` — corner radius in px; when unset the knob is a circle - `inactiveImage` / `activeImage` — idle / turning custom image - `idleTransparent` / `activeTransparent` — transparent background toggles +`ElementShadowSpec` contains `enabled`, `color`, `offsetX`, `offsetY`, and +`blur`. Offsets accept -100–100 px and blur accepts 0–100 px. + While the physical knob is turning, the element switches to its active colors/image (like a pressed key) and rotates by `accumulatedRevolutions × 360° × sensitivity`. diff --git a/docs/content/en/custom-css/counter-styling/page.mdx b/docs/content/en/custom-css/counter-styling/page.mdx index f4d9159b..ff9c401e 100644 --- a/docs/content/en/custom-css/counter-styling/page.mdx +++ b/docs/content/en/custom-css/counter-styling/page.mdx @@ -25,7 +25,7 @@ Set styles that apply to all counters: font-weight: 700; /* Font family */ - font-family: "Roboto Mono", monospace; + font-family: 'Roboto Mono', monospace; } ``` @@ -33,18 +33,27 @@ Set styles that apply to all counters: Counters support the following CSS variables: -| Variable | Description | Default | -| ------------------------ | ---------------------- | ------------- | -| `--counter-color` | Text color | `#FFFFFF` | -| `--counter-stroke-color` | Text outline color | `transparent` | -| `--counter-stroke-width` | Text outline thickness | `0px` | +| Variable | Description | Default | +| --------------------------- | ------------------------------- | -------------- | +| `--counter-color` | Text color and saved-fill reset | `#FFFFFF` | +| `--counter-fill-image` | Text fill image | `none` | +| `--counter-fill-clip` | Fill clipping area | `border-box` | +| `--counter-text-fill-color` | WebKit text fill color | `currentcolor` | +| `--counter-stroke-color` | Text outline color | `transparent` | +| `--counter-stroke-width` | Text outline thickness | `0px` | + + + With **Inline Styles Priority** disabled on the key or stat item, regular CSS + also takes priority over counter typography. Setting `--counter-color` + automatically removes a fill gradient saved in the properties panel. + ## Inactive State Style Counter style when the key is not pressed: ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { /* Text color */ --counter-color: #474244; @@ -58,7 +67,7 @@ Counter style when the key is not pressed: Counter style when the key is being pressed: ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { /* Text color */ --counter-color: #ff2b80; @@ -78,12 +87,12 @@ Combine class selectors to style counters of specific keys differently. ```css /* Counter for keys with .blue class - inactive state */ -.blue .counter[data-counter-state="inactive"] { +.blue .counter[data-counter-state='inactive'] { --counter-color: #1a4a5c; } /* Counter for keys with .blue class - active state */ -.blue .counter[data-counter-state="active"] { +.blue .counter[data-counter-state='active'] { --counter-color: #2ebef3; --counter-stroke-color: transparent; text-shadow: 0px 0px 3px #2ebef3; @@ -105,16 +114,13 @@ Combine class selectors to style counters of specific keys differently. font-weight: 700; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #333; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #00ff88; - text-shadow: - 0px 0px 2px #00ff88, - 0px 0px 4px #00ff88, - 0px 0px 8px #00ff88; + text-shadow: 0px 0px 2px #00ff88, 0px 0px 4px #00ff88, 0px 0px 8px #00ff88; } ``` @@ -126,13 +132,13 @@ Combine class selectors to style counters of specific keys differently. font-weight: 900; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #fff; --counter-stroke-color: #333; --counter-stroke-width: 2px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #fff; --counter-stroke-color: #ff2b80; --counter-stroke-width: 2px; @@ -145,14 +151,14 @@ Combine class selectors to style counters of specific keys differently. .counter { font-size: 14px; font-weight: 400; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #999; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #333; } ``` @@ -160,7 +166,7 @@ Combine class selectors to style counters of specific keys differently. ### Rainbow Gradient Style ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { background: linear-gradient( 45deg, #ff2b80, @@ -195,7 +201,7 @@ Example of keeping key and counter colors consistent: ```css /* Base key - pink theme */ -[data-state="active"] { +[data-state='active'] { --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; text-shadow: 0px 0px 3px #ff2b80; diff --git a/docs/content/en/custom-css/graph-styling/page.mdx b/docs/content/en/custom-css/graph-styling/page.mdx index 858d99c3..3606ddb6 100644 --- a/docs/content/en/custom-css/graph-styling/page.mdx +++ b/docs/content/en/custom-css/graph-styling/page.mdx @@ -27,19 +27,22 @@ When **Inline Styles Priority** is enabled, property panel values take precedenc For graphs, **Inline Styles Priority is disabled by default**, so CSS-variable-based control works immediately. - Recommended: use class selectors to style specific graphs instead of broad global selectors. + Recommended: use class selectors to style specific graphs instead of broad + global selectors. ## Graph CSS Variables Available graph variables: -| Variable | Description | Type | Example | -| ---------------- | ---------------------- | ---------- | ------------------------ | -| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | Graph border | `` | `1px solid #7dd3fc` | -| `--graph-radius` | Graph corner radius | `` | `10px` | -| `--graph-color` | Line/bar drawing color | `` | `#86efac` | +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | Border and saved border-ring reset | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | Graph inner padding | `` | `0px`, `3px` | +| `--graph-radius` | Graph corner radius | `` | `10px` | +| `--graph-color` | Line/bar drawing color | `` | `#86efac` | ### Example @@ -81,4 +84,5 @@ Available graph variables: ## Notes - Graph position offsets use the same variables as keys: `--key-offset-x`, `--key-offset-y`. -- To completely hide the border, use `--graph-border: none;` or set border width to `0` in the properties panel. +- `--graph-border: none;` removes the solid border, a gradient border ring + saved in the properties panel, and its image inset together. diff --git a/docs/content/en/custom-css/key-styling/page.mdx b/docs/content/en/custom-css/key-styling/page.mdx index b848e242..fde278be 100644 --- a/docs/content/en/custom-css/key-styling/page.mdx +++ b/docs/content/en/custom-css/key-styling/page.mdx @@ -14,13 +14,20 @@ Keys indicate their current state through the `data-state` attribute: - `data-state="inactive"`: Key not pressed - `data-state="active"`: Key being pressed + + With **Inline Styles Priority** disabled, property-panel solid colors, + gradients, borders, and typography are lower-priority defaults. Setting + `--key-bg` or `--key-border` also removes the corresponding background or + border gradient saved in the app. + + ## Global Key Styles Set styles that apply to all keys: ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { /* Corner roundness */ --key-radius: 8px; @@ -38,7 +45,7 @@ Set styles that apply to all keys: Styles when the key is not pressed: ```css -[data-state="inactive"] { +[data-state='inactive'] { /* Background color */ --key-bg: #2a2a2a; @@ -58,7 +65,7 @@ Styles when the key is not pressed: Styles when the key is being pressed: ```css -[data-state="active"] { +[data-state='active'] { /* Background color */ --key-bg: #ff2b80; @@ -81,7 +88,7 @@ Styles when the key is being pressed: You can implement a press effect where the key moves when pressed: ```css -[data-state="active"] { +[data-state='active'] { /* X offset */ --key-offset-x: 0px; @@ -113,7 +120,7 @@ Use class names to apply different styles to specific keys. ```css /* Active state for keys with .blue class */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #2ebef3; --key-text-color: #2ebef3; @@ -122,7 +129,7 @@ Use class names to apply different styles to specific keys. } /* Active state for keys with .special class */ -.special[data-state="active"] { +.special[data-state='active'] { --key-bg: transparent; background: linear-gradient(45deg, #ff2b80, #2ebef3); --key-border: none; @@ -135,49 +142,44 @@ Use class names to apply different styles to specific keys. - ✅ Correct: `blue` - ❌ Wrong: `.blue` - + ## Style Examples ### Neon Sign Style ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: transparent; --key-border: 3px solid #333; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; - text-shadow: - 0px 0px 2px #ff2b80, - 0px 0px 4px #ff2b80, - 0px 0px 8px #ff2b80; - box-shadow: - 0px 0px 4px #ff2b80, - inset 0px 0px 4px rgba(255, 43, 128, 0.3); + text-shadow: 0px 0px 2px #ff2b80, 0px 0px 4px #ff2b80, 0px 0px 8px #ff2b80; + box-shadow: 0px 0px 4px #ff2b80, inset 0px 0px 4px rgba(255, 43, 128, 0.3); } ``` ### Minimal Flat Style ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { --key-radius: 4px; transition: 0.05s ease-out; } -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: #f0f0f0; --key-border: none; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: #333; --key-border: none; --key-text-color: #fff; @@ -187,7 +189,7 @@ Use class names to apply different styles to specific keys. ### Pastel 3D Style ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 20px; --key-bg: #ffd4e5; --key-border: 2px solid #e8b4c8; @@ -195,7 +197,7 @@ Use class names to apply different styles to specific keys. box-shadow: 0px 4px 0px #d9a0b8; } -[data-state="active"] { +[data-state='active'] { --key-bg: #ffc4dd; --key-border: 2px solid #e8b4c8; --key-text-color: #8b5a6b; diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index dbbb0d4b..b04091d3 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -15,6 +15,7 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d | ------------------ | ------------------------ | ---------- | ------------------------ | | `--key-radius` | Corner roundness | `` | `8px`, `50%` | | `--key-bg` | Background color | `` | `#ff2b80` | +| `--key-bg-image` | Background image (gradient) | `` | `linear-gradient(90deg, #f0f, #0ff)` | | `--key-border` | Border style | `` | `2px solid #fff`, `none` | | `--key-text-color` | Text color | `` | `#ffffff` | | `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | @@ -23,7 +24,9 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d `--key-bg` is applied as `background-color`. To use gradients, specify `background` or `background-image` directly and set `--key-bg: transparent;` - if needed. + if needed. When a key color is set to a gradient in the app, it renders as + `background-image: var(--key-bg-image, ...)`, so you can override it with + `--key-bg-image` (solid keys never get an inline `background-image`). ### Usage Example diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index ecdd581a..9bfdb2ac 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -69,9 +69,41 @@ interface KeyPosition { className?: string; zIndex?: number; counter: KeyCounterSettings; + backgroundColor?: string; + activeBackgroundColor?: string; + borderColor?: string; + activeBorderColor?: string; + borderWidth?: number; // px, 미지정 시 기본 1 — 0이면 테두리 없음 + backgroundGradient?: GradientSpec | null; + activeBackgroundGradient?: GradientSpec | null; + borderGradient?: GradientSpec | null; + activeBorderGradient?: GradientSpec | null; + shadow?: ElementShadowSpec; + activeShadow?: ElementShadowSpec; +} + +interface GradientSpec { + angle: number; // 0 이상 360 미만 — 360은 0으로 정규화 (CSS 기준, 0 = 위·시계 방향) + stops: { color: string; pos: number }[]; // 2–8개, pos는 0–1 오름차순 +} + +interface ElementShadowSpec { + enabled: boolean; + color: string; // 알파값으로 농도 조절 + offsetX: number; // px, -100~100 + offsetY: number; // px, -100~100 + blur: number; // px, 0~100 } ``` +그라데이션 필드가 있으면 렌더에서 대응 단색 필드보다 우선하며, 저장 시 +대응 단색 필드는 첫 스톱 색으로 자동 동기화됩니다. 단색으로 되돌리려면 +그라데이션 필드를 `null`로 두고 단색 필드를 갱신하세요. + +`shadow`와 `activeShadow`는 대기·입력 그림자를 각각 제어합니다. 필드를 +생략하면 기존 기본 그림자가 유지되며, 그림자를 명시적으로 끄려면 +`enabled: false`인 spec을 저장하세요. + ```javascript const positions = await dmn.keys.getPositions(); console.log('4key 위치:', positions['4key']); diff --git a/docs/content/ko/api-reference/knobs/page.mdx b/docs/content/ko/api-reference/knobs/page.mdx index 4624d2b7..2470fa9b 100644 --- a/docs/content/ko/api-reference/knobs/page.mdx +++ b/docs/content/ko/api-reference/knobs/page.mdx @@ -53,11 +53,17 @@ type KnobItemPositions = Record; - `backgroundColor` / `activeBackgroundColor` — 대기 / 회전 중 배경색 - `borderColor` / `activeBorderColor` — 대기 / 회전 중 테두리 색상 -- `borderWidth` — 테두리 두께(px, 기본 3) +- `borderWidth` — 테두리 두께(px, 기본 1 — 0이면 테두리 없음) +- `backgroundGradient` / `activeBackgroundGradient` — 배경 그라데이션(GradientSpec, 지정 시 단색보다 우선) +- `borderGradient` / `activeBorderGradient` — 테두리 그라데이션(GradientSpec) +- `shadow` / `activeShadow` — 대기 / 회전 중 그림자(ElementShadowSpec, color 알파값으로 농도 조절) - `borderRadius` — 모서리 반경(px), 미지정 시 원형 - `inactiveImage` / `activeImage` — 대기 / 회전 중 커스텀 이미지 - `idleTransparent` / `activeTransparent` — 배경 투명 토글 +`ElementShadowSpec`은 `enabled`, `color`, `offsetX`, `offsetY`, `blur`로 +구성됩니다. 오프셋은 -100~100px, 흐림은 0~100px 범위입니다. + 물리 노브가 회전하는 동안 요소는 키의 눌림처럼 입력(active) 색상/이미지로 전환되며, `누적 회전수 × 360° × sensitivity`만큼 회전합니다. diff --git a/docs/content/ko/custom-css/counter-styling/page.mdx b/docs/content/ko/custom-css/counter-styling/page.mdx index d75d83b9..6095d9fb 100644 --- a/docs/content/ko/custom-css/counter-styling/page.mdx +++ b/docs/content/ko/custom-css/counter-styling/page.mdx @@ -25,7 +25,7 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 font-weight: 700; /* 폰트 패밀리 */ - font-family: "Roboto Mono", monospace; + font-family: 'Roboto Mono', monospace; } ``` @@ -33,18 +33,27 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 카운터는 다음 CSS 변수를 지원합니다: -| 변수 | 설명 | 기본값 | -| ------------------------ | ------------------ | ------------- | -| `--counter-color` | 텍스트 색상 | `#FFFFFF` | -| `--counter-stroke-color` | 텍스트 외곽선 색상 | `transparent` | -| `--counter-stroke-width` | 텍스트 외곽선 두께 | `0px` | +| 변수 | 설명 | 기본값 | +| --------------------------- | ------------------------------ | -------------- | +| `--counter-color` | 텍스트 색상과 저장된 fill 해제 | `#FFFFFF` | +| `--counter-fill-image` | 텍스트 fill 이미지 | `none` | +| `--counter-fill-clip` | fill 클립 영역 | `border-box` | +| `--counter-text-fill-color` | WebKit 텍스트 채움색 | `currentcolor` | +| `--counter-stroke-color` | 텍스트 외곽선 색상 | `transparent` | +| `--counter-stroke-width` | 텍스트 외곽선 두께 | `0px` | + + + 키/통계의 **인라인 스타일 우선**을 끄면 카운터 글꼴도 일반 CSS가 우선합니다. + `--counter-color`를 지정하면 속성 패널에 저장된 fill 그라데이션도 자동으로 + 사라집니다. + ## 비활성 상태 스타일 키를 누르지 않았을 때의 카운터 스타일입니다: ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { /* 텍스트 색상 */ --counter-color: #474244; @@ -58,7 +67,7 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 키를 누르고 있을 때의 카운터 스타일입니다: ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { /* 텍스트 색상 */ --counter-color: #ff2b80; @@ -78,12 +87,12 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 ```css /* .blue 클래스를 가진 키의 카운터 - 비활성 상태 */ -.blue .counter[data-counter-state="inactive"] { +.blue .counter[data-counter-state='inactive'] { --counter-color: #1a4a5c; } /* .blue 클래스를 가진 키의 카운터 - 활성 상태 */ -.blue .counter[data-counter-state="active"] { +.blue .counter[data-counter-state='active'] { --counter-color: #2ebef3; --counter-stroke-color: transparent; text-shadow: 0px 0px 3px #2ebef3; @@ -105,11 +114,11 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 font-weight: 700; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #333; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #00ff88; text-shadow: 0px 0px 2px #00ff88, 0px 0px 4px #00ff88, 0px 0px 8px #00ff88; } @@ -123,13 +132,13 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 font-weight: 900; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #fff; --counter-stroke-color: #333; --counter-stroke-width: 2px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #fff; --counter-stroke-color: #ff2b80; --counter-stroke-width: 2px; @@ -142,14 +151,14 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 .counter { font-size: 14px; font-weight: 400; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #999; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #333; } ``` @@ -157,7 +166,7 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 ### 레인보우 그라데이션 스타일 ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { background: linear-gradient( 45deg, #ff2b80, @@ -192,25 +201,25 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 ```css /* 기본 키 - 핑크 테마 */ -[data-state="active"] { +[data-state='active'] { --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; text-shadow: 0px 0px 3px #ff2b80; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; text-shadow: 0px 0px 3px #ff2b80; } /* 파란색 키 - 블루 테마 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-border: 3px solid #2ebef3; --key-text-color: #2ebef3; text-shadow: 0px 0px 3px #2ebef3; } -.blue .counter[data-counter-state="active"] { +.blue .counter[data-counter-state='active'] { --counter-color: #2ebef3; text-shadow: 0px 0px 3px #2ebef3; } @@ -225,7 +234,7 @@ description: CSS로 키 카운터의 모양과 효과를 커스터마이징하 ```css .counter { font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; + font-feature-settings: 'tnum'; } ``` diff --git a/docs/content/ko/custom-css/graph-styling/page.mdx b/docs/content/ko/custom-css/graph-styling/page.mdx index b9031d09..e36705c8 100644 --- a/docs/content/ko/custom-css/graph-styling/page.mdx +++ b/docs/content/ko/custom-css/graph-styling/page.mdx @@ -27,19 +27,22 @@ description: CSS로 그래프 요소의 배경, 테두리, 선 색상, 클래스 그래프는 기본적으로 **인라인 스타일 우선이 꺼져 있으므로**, CSS 변수 기반 제어를 바로 사용할 수 있습니다. - 권장: 특정 그래프에만 스타일을 적용하려면 전역 선택자 대신 클래스 선택자를 사용하세요. + 권장: 특정 그래프에만 스타일을 적용하려면 전역 선택자 대신 클래스 선택자를 + 사용하세요. ## 그래프 CSS 변수 그래프에서 사용할 수 있는 변수는 아래와 같습니다. -| 변수 | 설명 | 타입 | 예시 | -| ---------------- | ----------- | ---------- | ------------------------ | -| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | 그래프 테두리 | `` | `1px solid #7dd3fc` | -| `--graph-radius` | 그래프 라운딩 | `` | `10px` | -| `--graph-color` | 라인/바 색상 | `` | `#86efac` | +| 변수 | 설명 | 타입 | 예시 | +| ---------------------- | ----------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | 그래프 테두리와 저장된 보더 링 해제 | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | 그래프 내부 여백 | `` | `0px`, `3px` | +| `--graph-radius` | 그래프 라운딩 | `` | `10px` | +| `--graph-color` | 라인/바 색상 | `` | `#86efac` | ### 사용 예시 @@ -81,4 +84,5 @@ description: CSS로 그래프 요소의 배경, 테두리, 선 색상, 클래스 ## 참고 - 그래프의 위치 이동 효과는 키와 동일하게 `--key-offset-x`, `--key-offset-y`를 사용합니다. -- 테두리를 완전히 숨기려면 `--graph-border: none;` 또는 속성 패널 테두리 두께를 `0`으로 설정하세요. +- `--graph-border: none;`은 단색 테두리뿐 아니라 속성 패널에 저장된 그라데이션 + 보더 링과 이미지 인셋도 함께 제거합니다. diff --git a/docs/content/ko/custom-css/key-styling/page.mdx b/docs/content/ko/custom-css/key-styling/page.mdx index 3a4e23a7..da146a1e 100644 --- a/docs/content/ko/custom-css/key-styling/page.mdx +++ b/docs/content/ko/custom-css/key-styling/page.mdx @@ -14,13 +14,19 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 - `data-state="inactive"`: 키를 누르지 않은 상태 - `data-state="active"`: 키를 누르고 있는 상태 + + **인라인 스타일 우선**을 끄면 속성 패널의 단색·그라데이션·보더·글꼴은 CSS보다 + 낮은 기본값으로만 적용됩니다. 이 상태에서 `--key-bg`와 `--key-border`를 + 지정하면 앱에 저장된 배경 그라데이션과 보더 그라데이션도 각각 사라집니다. + + ## 전역 키 스타일 모든 키에 공통으로 적용되는 스타일을 설정합니다: ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { /* 모서리 둥글기 */ --key-radius: 8px; @@ -38,7 +44,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 키를 누르지 않았을 때의 스타일입니다: ```css -[data-state="inactive"] { +[data-state='inactive'] { /* 배경색 */ --key-bg: #2a2a2a; @@ -58,7 +64,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 키를 누르고 있을 때의 스타일입니다: ```css -[data-state="active"] { +[data-state='active'] { /* 배경색 */ --key-bg: #ff2b80; @@ -81,7 +87,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 키를 눌렀을 때 위치가 이동하는 눌림 효과를 구현할 수 있습니다: ```css -[data-state="active"] { +[data-state='active'] { /* X축 오프셋 */ --key-offset-x: 0px; @@ -113,7 +119,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 ```css /* .blue 클래스를 가진 키의 활성 상태 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #2ebef3; --key-text-color: #2ebef3; @@ -122,7 +128,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 } /* .special 클래스를 가진 키의 활성 상태 */ -.special[data-state="active"] { +.special[data-state='active'] { --key-bg: transparent; background: linear-gradient(45deg, #ff2b80, #2ebef3); --key-border: none; @@ -133,22 +139,22 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 클래스명 입력 시 선택자 기호(`.`)를 제외하고 이름만 입력하세요. - - ✅ 올바름: `blue` - - ❌ 잘못됨: `.blue` - +- ✅ 올바름: `blue` +- ❌ 잘못됨: `.blue` + ## 스타일 예제 ### 네온 사인 스타일 ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: transparent; --key-border: 3px solid #333; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; @@ -160,19 +166,19 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 ### 미니멀 플랫 스타일 ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { --key-radius: 4px; transition: 0.05s ease-out; } -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: #f0f0f0; --key-border: none; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: #333; --key-border: none; --key-text-color: #fff; @@ -182,7 +188,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 ### 파스텔 3D 스타일 ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 20px; --key-bg: #ffd4e5; --key-border: 2px solid #e8b4c8; @@ -190,7 +196,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 box-shadow: 0px 4px 0px #d9a0b8; } -[data-state="active"] { +[data-state='active'] { --key-bg: #ffc4dd; --key-border: 2px solid #e8b4c8; --key-text-color: #8b5a6b; @@ -204,7 +210,7 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 ### 그라데이션 배경 ```css -[data-state="active"] { +[data-state='active'] { --key-bg: transparent; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } @@ -223,18 +229,18 @@ description: CSS로 키의 모양과 상태 효과를 커스터마이징하는 } } -[data-state="active"] { +[data-state='active'] { animation: pulse 0.5s ease-in-out infinite; } ``` ### !important 사용 -일부 속성이 적용되지 않는 경우 `!important`를 사용해 보세요: +**인라인 스타일 우선**을 켠 요소까지 CSS로 강제 덮어쓸 때만 `!important`를 +사용하세요: ```css -[data-state="active"] { +[data-state='active'] { --key-bg: #ff2b80 !important; } ``` - diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index 18e3bac3..2bc30022 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -15,6 +15,7 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | ------------------ | ------------------------ | ---------- | ------------------------ | | `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | | `--key-bg` | 키의 배경색 | `` | `#ff2b80` | +| `--key-bg-image` | 키의 배경 이미지(그라데이션) | `` | `linear-gradient(90deg, #f0f, #0ff)` | | `--key-border` | 키의 테두리 스타일 | `` | `2px solid #fff`, `none` | | `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | | `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | @@ -23,7 +24,9 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 `--key-bg`는 `background-color`로 적용됩니다. 그라데이션을 사용하려면 `background` 또는 `background-image`를 직접 지정하고, 필요하면 `--key-bg: - transparent;`로 설정하세요. + transparent;`로 설정하세요. 앱에서 키 색상을 그라데이션으로 설정한 경우에는 + `background-image: var(--key-bg-image, ...)`로 렌더되므로 `--key-bg-image`로 + 덮어쓸 수 있습니다 (단색 키에는 인라인 `background-image`가 붙지 않습니다). ### 사용 예시 diff --git a/src-tauri/default_positions.json b/src-tauri/default_positions.json index 197e2abb..c0250633 100644 --- a/src-tauri/default_positions.json +++ b/src-tauri/default_positions.json @@ -1 +1 @@ -{"4key":[{"dx":200,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":585,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"5key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"6key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"8key":[{"dx":135,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#ED005C","noteOpacity":80},{"dx":455,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#ED005C","noteOpacity":80},{"dx":260,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}]} \ No newline at end of file +{"4key":[{"dx":200,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"5key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"6key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"8key":[{"dx":135,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}]} \ No newline at end of file diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index a818874c..8024e58f 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -18,9 +18,10 @@ use crate::{ errors::{CmdResult, CommandError}, models::{ AppStoreData, CommittedEditorChange, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, - EditorCommitOrigin, EditorField, FontSettings, FontType, GraphPositions, KeyMappings, - KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, NoteSettingsPatch, - SettingsPatchInput, StatPositions, TabCssOverrides, TabNoteSettings, + EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec, GraphPositions, + KeyMappings, KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, + NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCssOverrides, TabNoteSettings, + SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, services::settings::apply_patch_to_store, state::AppState, @@ -52,7 +53,132 @@ fn apply_editor_runtime_best_effort( fn read_preset_file(path: &Path) -> CmdResult { let content = fs::read_to_string(path)?; - serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset")) + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; + if let Some(detail) = invalid_position_style_detail(&value) { + return Err(CommandError::msg(format!("invalid-preset: {detail}"))); + } + serde_json::from_value(value).map_err(|_| CommandError::msg("invalid-preset")) +} + +fn invalid_position_style_detail(preset: &serde_json::Value) -> Option { + const COLLECTIONS: [&str; 4] = [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ]; + const ELEMENT_FIELDS: [&str; 4] = [ + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + ]; + const COUNTER_FIELDS: [&str; 2] = ["fillIdleGradient", "fillActiveGradient"]; + const SHADOW_FIELDS: [&str; 2] = ["shadow", "activeShadow"]; + + for collection_name in COLLECTIONS { + let Some(modes) = preset + .get(collection_name) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + for (mode, entries) in modes { + let Some(entries) = entries.as_array() else { + continue; + }; + for (index, entry) in entries.iter().enumerate() { + let Some(entry) = entry.as_object() else { + continue; + }; + for field in ELEMENT_FIELDS { + if let Some(error) = invalid_gradient_error(entry.get(field)) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}: {error}" + )); + } + } + for field in SHADOW_FIELDS { + // null은 Option 역직렬화와 동일하게 "값 없음" 취급 + let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { + continue; + }; + if let Some((suffix, error)) = invalid_shadow_error(value) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" + )); + } + } + let Some(counter) = entry.get("counter").and_then(serde_json::Value::as_object) + else { + continue; + }; + for field in COUNTER_FIELDS { + if let Some(error) = invalid_gradient_error(counter.get(field)) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].counter.{field}: {error}" + )); + } + } + } + } + } + None +} + +fn invalid_gradient_error(value: Option<&serde_json::Value>) -> Option { + value.and_then(|value| serde_json::from_value::>(value.clone()).err()) +} + +fn invalid_shadow_error(value: &serde_json::Value) -> Option<(&'static str, &'static str)> { + let Some(shadow) = value.as_object() else { + return Some(("", "must be an object")); + }; + if !shadow + .get("enabled") + .is_some_and(serde_json::Value::is_boolean) + { + return Some((".enabled", "must be a boolean")); + } + if shadow + .get("color") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + { + return Some((".color", "must be a non-empty string")); + } + for field in ["offsetX", "offsetY"] { + if !shadow + .get(field) + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value) + }) + { + let suffix = if field == "offsetX" { + ".offsetX" + } else { + ".offsetY" + }; + return Some((suffix, "must be a finite number between -100 and 100")); + } + } + if !shadow + .get("blur") + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(&value) + }) + { + return Some((".blur", "must be a finite number between 0 and 100")); + } + None +} + +#[cfg(test)] +pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { + read_preset_file(path) } struct ResolvedFullPresetSettings { @@ -1339,6 +1465,281 @@ mod tests { let _ = std::fs::remove_dir_all(temp_dir); } + #[test] + fn damaged_gradient_preset_reports_element_and_field_with_existing_error_code() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-gradient-error-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + let damaged_gradient = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }); + + for (collection, field, counter_field) in [ + ("keyPositions", "backgroundGradient", false), + ("statPositions", "activeBackgroundGradient", false), + ("graphPositions", "borderGradient", false), + ("knobPositions", "activeBorderGradient", false), + ("keyPositions", "fillIdleGradient", true), + ("statPositions", "fillActiveGradient", true), + ] { + let mut gradient_fields = serde_json::Map::new(); + gradient_fields.insert(field.to_string(), damaged_gradient.clone()); + let damaged_entry = if counter_field { + let mut entry = serde_json::Map::new(); + entry.insert( + "counter".to_string(), + serde_json::Value::Object(gradient_fields), + ); + serde_json::Value::Object(entry) + } else { + serde_json::Value::Object(gradient_fields) + }; + let mut modes = serde_json::Map::new(); + modes.insert( + "custom mode".to_string(), + serde_json::json!([{}, damaged_entry]), + ); + let mut preset = serde_json::Map::new(); + preset.insert(collection.to_string(), serde_json::Value::Object(modes)); + std::fs::write( + &source_path, + serde_json::to_vec(&serde_json::Value::Object(preset)).unwrap(), + ) + .unwrap(); + + let error = read_preset_file(&source_path) + .err() + .expect("damaged gradient preset must be rejected") + .to_string(); + let field_path = if counter_field { + format!("counter.{field}") + } else { + field.to_string() + }; + let expected_prefix = + format!("invalid-preset: {collection}[\"custom mode\"][1].{field_path}: "); + assert!( + error.starts_with(&expected_prefix), + "unexpected gradient error: {error}" + ); + assert!(error.contains("gradient must contain at least two stops")); + } + + std::fs::write(&source_path, b"{ invalid preset").unwrap(); + assert_eq!( + read_preset_file(&source_path) + .err() + .expect("invalid JSON preset must be rejected") + .to_string(), + "invalid-preset" + ); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn invalid_shadow_preset_reports_exact_element_paths() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-error-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + for (collection, entry, expected_path, expected_reason) in [ + ( + "keyPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 0, + "blur": 100.1 + } + }), + "shadow.blur", + "must be a finite number between 0 and 100", + ), + ( + "statPositions", + serde_json::json!({ + "activeShadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100.1, + "offsetY": 0, + "blur": 12 + } + }), + "activeShadow.offsetX", + "must be a finite number between -100 and 100", + ), + ( + "graphPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 100.1, + "blur": 12 + } + }), + "shadow.offsetY", + "must be a finite number between -100 and 100", + ), + ( + "knobPositions", + serde_json::json!({ "activeShadow": [] }), + "activeShadow", + "must be an object", + ), + ( + "keyPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "", + "offsetX": 0, + "offsetY": 0, + "blur": 12 + } + }), + "shadow.color", + "must be a non-empty string", + ), + ] { + let preset = serde_json::json!({ + (collection): { + "custom mode": [{}, entry] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); + + let error = read_preset_file(&source_path) + .err() + .expect("invalid shadow preset must be rejected") + .to_string(); + assert_eq!( + error, + format!( + "invalid-preset: {collection}[\"custom mode\"][1].{expected_path}: {expected_reason}" + ) + ); + } + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn null_shadow_fields_are_treated_as_absent() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-null-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + // 외부 생성·수동 편집 프리셋의 명시적 null은 Option 역직렬화처럼 값 없음 + let preset = serde_json::json!({ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": null, + "activeShadow": null + }] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); + + let parsed = read_preset_file(&source_path).expect("null fields must parse as absent"); + let position = &parsed.key_positions.as_ref().unwrap()["4key"][0]; + assert!(position.shadow.is_none()); + assert!(position.active_shadow.is_none()); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn legacy_and_bounded_shadow_presets_still_parse() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-compatibility-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + let legacy = serde_json::json!({ + "keyPositions": { + "4key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + let parsed_legacy = read_preset_file(&source_path).unwrap(); + let legacy_position = &parsed_legacy.key_positions.unwrap()["4key"][0]; + assert!(legacy_position.shadow.is_none()); + assert!(legacy_position.active_shadow.is_none()); + + let position = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100, + "offsetY": 100, + "blur": 100 + }, + "activeShadow": { + "enabled": false, + "color": "rgba(0, 0, 0, 0)", + "offsetX": 100, + "offsetY": -100, + "blur": 0 + } + }); + let mut stat = position.clone(); + stat.as_object_mut() + .unwrap() + .insert("statType".to_string(), serde_json::json!("kps")); + let mut graph = position.clone(); + graph + .as_object_mut() + .unwrap() + .extend(serde_json::Map::from_iter([ + ("statType".to_string(), serde_json::json!("kps")), + ("graphType".to_string(), serde_json::json!("line")), + ("graphSpeed".to_string(), serde_json::json!(100)), + ("graphColor".to_string(), serde_json::json!("#123456")), + ])); + let bounded = serde_json::json!({ + "keyPositions": { "4key": [position.clone()] }, + "statPositions": { "4key": [stat] }, + "graphPositions": { "4key": [graph] }, + "knobPositions": { "4key": [position] } + }); + std::fs::write(&source_path, serde_json::to_vec(&bounded).unwrap()).unwrap(); + let parsed = read_preset_file(&source_path).unwrap(); + assert_eq!( + parsed.key_positions.as_ref().unwrap()["4key"][0] + .shadow + .as_ref() + .unwrap() + .blur, + 100.0 + ); + let _ = std::fs::remove_dir_all(temp_dir); + } + #[test] fn missing_legacy_global_fields_preserve_current_plugin_and_style_settings() { let current = AppStoreData { diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index a4fe9261..60f0281c 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -243,7 +243,8 @@ mod tests { // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 #[cfg(not(target_os = "windows"))] use super::local_source_path_from_image_ref; - use crate::models::NoteColor; + use crate::models::{KeyCounterColor, NoteColor}; + use serde::Deserialize; use serde_json::json; #[test] @@ -273,6 +274,83 @@ mod tests { assert!(preset.tab_css_overrides.is_none()); } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeaturePresetPosition { + background_color: Option, + counter: PreFeaturePresetCounter, + } + + #[derive(Deserialize)] + struct PreFeaturePresetCounter { + fill: KeyCounterColor, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeaturePreset { + key_positions: Option>>, + } + + #[test] + fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "rgba(16, 32, 48, 1)", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, + { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + }, + "fillIdleGradient": { + "angle": 180, + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + } + } + }] + } + }); + let preset: PresetFile = serde_json::from_value(source).unwrap(); + let serialized = serde_json::to_value(&preset).unwrap(); + let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); + let reserialized = serde_json::to_value(restored).unwrap(); + + assert_eq!(reserialized, serialized); + assert_eq!( + serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), + Some(90.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), + Some(180.0) + ); + + let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); + let shadow_positions = shadow.key_positions.unwrap(); + let shadow_position = &shadow_positions["4key"][0]; + assert_eq!( + shadow_position.background_color.as_deref(), + Some("rgba(16, 32, 48, 1)") + ); + assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); + } + #[test] fn tauri_era_preset_schema_transitions_remain_readable() { let tauri_130: PresetFile = serde_json::from_value(json!({ @@ -575,6 +653,6 @@ mod tests { assert_eq!(position.count, 42); assert_eq!(position.height, 60.0); assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity, 90); } } diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index d1e2e348..32abf7d0 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -247,6 +247,12 @@ fn write_preset_file(path: &Path, json: &str) -> CmdResult<()> { Ok(()) } +#[cfg(test)] +pub(crate) fn write_preset_file_for_simulation(path: &Path, preset: &PresetFile) -> CmdResult<()> { + let json = serde_json::to_string_pretty(preset)?; + write_preset_file(path, &json) +} + fn collect_used_font_families( key_positions: &KeyPositions, stat_positions: &StatPositions, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index bff87a41..968c9c76 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -4,7 +4,7 @@ pub mod obs; pub use editor::*; use serde::de::Error as DeError; -use serde::ser::SerializeMap; +use serde::ser::{Error as SerError, SerializeMap}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; use std::path::Path; @@ -17,6 +17,117 @@ pub type StatPositions = HashMap>; pub type GraphPositions = HashMap>; pub type KnobPositions = HashMap>; +const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; +const MAX_GRADIENT_STOPS: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GradientStop { + pub color: String, + pub pos: f64, +} + +#[derive(Debug, Clone)] +pub struct GradientSpec { + pub angle: f64, + pub stops: Vec, + normalized_on_read: bool, +} + +impl PartialEq for GradientSpec { + fn eq(&self, other: &Self) -> bool { + self.angle == other.angle && self.stops == other.stops + } +} + +impl GradientSpec { + fn normalize(&mut self) -> bool { + let previous_angle = self.angle; + let previous_stops = self.stops.clone(); + + self.angle = self.angle.rem_euclid(360.0); + if self.angle == 0.0 { + self.angle = 0.0; + } + for stop in &mut self.stops { + stop.pos = stop.pos.clamp(0.0, 1.0); + if stop.pos == 0.0 { + stop.pos = 0.0; + } + } + self.stops + .sort_by(|left, right| left.pos.total_cmp(&right.pos)); + self.stops.truncate(MAX_GRADIENT_STOPS); + + self.angle != previous_angle || self.stops != previous_stops + } + + fn canonicalize(&mut self) -> bool { + let normalized_on_read = std::mem::take(&mut self.normalized_on_read); + self.normalize() | normalized_on_read + } +} + +impl Serialize for GradientSpec { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.stops.len() < 2 { + return Err(S::Error::custom("gradient must contain at least two stops")); + } + if !self.angle.is_finite() || self.stops.iter().any(|stop| !stop.pos.is_finite()) { + return Err(S::Error::custom("gradient values must be finite")); + } + + let mut canonical = self.clone(); + canonical.normalize(); + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("angle", &canonical.angle)?; + map.serialize_entry("stops", &canonical.stops)?; + map.end() + } +} + +impl<'de> Deserialize<'de> for GradientSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let serde_json::Value::Object(mut input) = value else { + return Err(D::Error::custom("gradient must be an object")); + }; + let angle_value = input.remove("angle"); + let angle = match angle_value.as_ref() { + Some(value) => serde_json::from_value::(value.clone()) + .map_err(|error| D::Error::custom(format!("invalid gradient angle: {error}")))?, + None => DEFAULT_GRADIENT_ANGLE, + }; + let stops = input + .remove("stops") + .ok_or_else(|| D::Error::custom("gradient stops are required")) + .and_then(|value| { + serde_json::from_value::>(value) + .map_err(|error| D::Error::custom(format!("invalid gradient stops: {error}"))) + })?; + if stops.len() < 2 { + return Err(D::Error::custom("gradient must contain at least two stops")); + } + if !angle.is_finite() || stops.iter().any(|stop| !stop.pos.is_finite()) { + return Err(D::Error::custom("gradient values must be finite")); + } + + let mut gradient = Self { + angle, + stops, + normalized_on_read: angle_value.is_none() || !input.is_empty(), + }; + gradient.normalized_on_read |= gradient.normalize(); + Ok(gradient) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum NoteColor { Solid(String), @@ -182,6 +293,22 @@ pub enum NoteAlignment { Right, } +// 그림자 범위 계약 — 프론트 zod(ELEMENT_SHADOW_CONSTRAINTS)와 동기 유지 +pub const SHADOW_OFFSET_MIN: f64 = -100.0; +pub const SHADOW_OFFSET_MAX: f64 = 100.0; +pub const SHADOW_BLUR_MIN: f64 = 0.0; +pub const SHADOW_BLUR_MAX: f64 = 100.0; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ElementShadowSpec { + pub enabled: bool, + pub color: String, + pub offset_x: f64, + pub offset_y: f64, + pub blur: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KeyPosition { @@ -270,16 +397,28 @@ pub struct KeyPosition { // 스타일 관련 속성들 #[serde(default)] pub background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_gradient: Option, #[serde(default)] pub active_background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_background_gradient: Option, #[serde(default)] pub border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border_gradient: Option, #[serde(default)] pub active_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_border_gradient: Option, #[serde(default)] pub border_width: Option, #[serde(default)] pub border_radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_shadow: Option, #[serde(default)] pub font_size: Option, #[serde(default)] @@ -365,11 +504,17 @@ impl Default for KeyPosition { z_index: None, counter: KeyCounterSettings::default(), background_color: None, + background_gradient: None, active_background_color: None, + active_background_gradient: None, border_color: None, + border_gradient: None, active_border_color: None, + active_border_gradient: None, border_width: None, border_radius: None, + shadow: None, + active_shadow: None, font_size: None, font_color: None, active_font_color: None, @@ -472,8 +617,9 @@ pub enum KeyCounterPlacement { #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum KeyCounterAlign { - #[default] Top, + // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 + #[default] Bottom, Left, Right, @@ -498,9 +644,9 @@ pub struct KeyCounterColor { impl Default for KeyCounterColor { fn default() -> Self { Self { - // 렌더러 기본값과 일치 (src/types/keys.ts) - idle: "rgba(121, 121, 121, 0.9)".to_string(), - active: "#FFFFFF".to_string(), + // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) + idle: "rgba(237, 238, 242, 0.78)".to_string(), + active: "rgba(20, 20, 24, 0.9)".to_string(), } } } @@ -752,6 +898,10 @@ pub struct KeyCounterSettings { pub align_mode: KeyCounterAlignMode, #[serde(default)] pub fill: KeyCounterColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_idle_gradient: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_active_gradient: Option, #[serde(default = "default_stroke_color")] pub stroke: KeyCounterColor, #[serde(default = "default_gap")] @@ -786,9 +936,11 @@ impl Default for KeyCounterSettings { Self { enabled: true, placement: KeyCounterPlacement::Inside, - align: KeyCounterAlign::Top, + align: KeyCounterAlign::Bottom, align_mode: KeyCounterAlignMode::Center, fill: KeyCounterColor::default(), + fill_idle_gradient: None, + fill_active_gradient: None, stroke: default_stroke_color(), gap: default_gap(), font_size: default_counter_font_size(), @@ -811,29 +963,237 @@ impl KeyCounterSettings { /// This keeps existing user customizations intact, while fixing the old default /// active fill/stroke values (black text / outlined) that diverged from the renderer. pub fn migrate_legacy_defaults(&mut self) -> bool { + if self.fill_idle_gradient.is_some() || self.fill_active_gradient.is_some() { + self.normalize(); + return false; + } + + // 당시 직렬화되던 스냅샷 값 고정 — 현재 기본값 함수와 결합 금지 let looks_like_legacy_default = self.fill.idle == "#FFFFFF" && self.fill.active == "#000000" && self.stroke.idle == "#000000" && self.stroke.active == "#FFFFFF" - && self.gap == default_gap() - && self.font_size == default_counter_font_size() + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 && self.font_weight == 400 && self.font_family.is_none() && !self.font_italic && !self.font_underline && !self.font_strikethrough; - if !looks_like_legacy_default { + if looks_like_legacy_default { + self.fill = KeyCounterColor::default(); + self.stroke = default_stroke_color(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); + self.animation = KeyCounterAnimationSettings::default(); self.normalize(); - return false; + return true; + } + + // 직전 기본값 스냅샷(회색 카운터·16px·700·상단 배치) → 글래스 기본 디자인으로 승격 + // 전 필드 일치일 때만 — 하나라도 다르면 사용자 커스텀으로 보고 유지 + let looks_like_previous_default = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.stroke.idle == "transparent" + && self.stroke.active == "transparent" + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_weight == 700 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + + if looks_like_previous_default { + self.fill = KeyCounterColor::default(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); + self.normalize(); + return true; } - self.fill = KeyCounterColor::default(); - self.stroke = default_stroke_color(); - self.font_weight = default_counter_font_weight(); - self.animation = KeyCounterAnimationSettings::default(); self.normalize(); - true + false + } + + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + let (idle_changed, idle_pair_repaired) = + canonicalize_counter_gradient_pair(&mut self.fill.idle, &mut self.fill_idle_gradient); + changed |= idle_changed; + pair_repaired |= idle_pair_repaired; + + let (active_changed, active_pair_repaired) = canonicalize_counter_gradient_pair( + &mut self.fill.active, + &mut self.fill_active_gradient, + ); + changed |= active_changed; + pair_repaired |= active_pair_repaired; + + (changed, pair_repaired) + } +} + +impl KeyPosition { + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + for (base, gradient) in [ + (&mut self.background_color, &mut self.background_gradient), + ( + &mut self.active_background_color, + &mut self.active_background_gradient, + ), + (&mut self.border_color, &mut self.border_gradient), + ( + &mut self.active_border_color, + &mut self.active_border_gradient, + ), + ] { + let (pair_changed, base_repaired) = canonicalize_optional_gradient_pair(base, gradient); + changed |= pair_changed; + pair_repaired |= base_repaired; + } + + let (counter_changed, counter_pair_repaired) = self.counter.canonicalize_gradient_pairs(); + changed |= counter_changed; + pair_repaired |= counter_pair_repaired; + + (changed, pair_repaired) + } +} + +fn canonicalize_optional_gradient_pair( + base: &mut Option, + gradient: &mut Option, +) -> (bool, bool) { + let Some(gradient) = gradient else { + return (false, false); + }; + + let mut changed = gradient.canonicalize(); + let representative = gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color + .clone(); + let pair_repaired = base.as_deref() != Some(representative.as_str()); + if pair_repaired { + *base = Some(representative); + changed = true; + } + (changed, pair_repaired) +} + +fn canonicalize_counter_gradient_pair( + base: &mut String, + gradient: &mut Option, +) -> (bool, bool) { + let Some(gradient) = gradient else { + return (false, false); + }; + + let mut changed = gradient.canonicalize(); + let representative = compact_canonical_rgba( + &gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color, + ); + let pair_repaired = *base != representative; + if pair_repaired { + *base = representative; + changed = true; + } + (changed, pair_repaired) +} + +fn compact_canonical_rgba(color: &str) -> String { + let trimmed = color.trim(); + if let Some(hex) = trimmed.strip_prefix('#') { + if matches!(hex.len(), 3 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let expanded; + let hex = if hex.len() == 3 { + expanded = hex + .chars() + .flat_map(|character| [character, character]) + .collect::(); + expanded.as_str() + } else { + hex + }; + let red = u8::from_str_radix(&hex[0..2], 16).expect("validated hex channel"); + let green = u8::from_str_radix(&hex[2..4], 16).expect("validated hex channel"); + let blue = u8::from_str_radix(&hex[4..6], 16).expect("validated hex channel"); + let alpha = if hex.len() == 8 { + f64::from(u8::from_str_radix(&hex[6..8], 16).expect("validated alpha channel")) + / 255.0 + } else { + 1.0 + }; + return format!("rgba({red},{green},{blue},{})", format_compact_alpha(alpha)); + } + } + + let functional = trimmed + .strip_prefix("rgba(") + .or_else(|| trimmed.strip_prefix("rgb(")); + if let Some(body) = functional { + if let Some(body) = body.strip_suffix(')') { + let channels = body.split(',').map(str::trim).collect::>(); + if matches!(channels.len(), 3 | 4) + && channels.iter().all(|channel| { + !channel.is_empty() + && channel + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + }) + { + let parsed = channels + .iter() + .map(|channel| channel.parse::()) + .collect::, _>>(); + if let Ok(parsed) = parsed { + let alpha = parsed.get(3).copied().unwrap_or(1.0); + return format!( + "rgba({},{},{},{})", + parsed[0].round() as i64, + parsed[1].round() as i64, + parsed[2].round() as i64, + format_compact_alpha(alpha) + ); + } + } + } + } + + trimmed.to_string() +} + +fn format_compact_alpha(alpha: f64) -> String { + let rounded = (alpha.clamp(0.0, 1.0) * 10_000.0).round() / 10_000.0; + let formatted = format!("{rounded:.4}"); + let compact = formatted.trim_end_matches('0').trim_end_matches('.'); + if compact.is_empty() { + "0".to_string() + } else { + compact.to_string() } } @@ -854,13 +1214,13 @@ fn default_counter_animation_duration_ms() -> u32 { } fn default_gap() -> u32 { - 6 + 4 } fn default_counter_font_size() -> u32 { - 16 + 11 } fn default_counter_font_weight() -> u32 { - 700 + 500 } fn default_counter_enabled() -> bool { @@ -876,7 +1236,7 @@ fn default_key_note_color() -> NoteColor { NoteColor::Solid("#FFFFFF".to_string()) } fn default_key_note_opacity() -> u32 { - 80 + 90 } fn default_note_glow_enabled() -> bool { false @@ -960,8 +1320,8 @@ impl Default for NoteSettings { Self { border_radius: None, frame_limit: default_note_frame_limit(), - speed: 180, - track_height: 150, + speed: 400, + track_height: 300, reverse: false, fade_position: FadePosition::Auto, fade_top_px: 50, @@ -1953,7 +2313,12 @@ pub struct SettingsPatch { #[cfg(test)] mod tests { - use super::{FadePosition, KeyPosition, NoteColor, NoteSettings, StatType}; + use super::{ + compact_canonical_rgba, FadePosition, GradientSpec, KeyCounterAlign, KeyCounterAlignMode, + KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyPosition, NoteColor, + NoteSettings, StatType, + }; + use serde::Deserialize; #[test] fn stat_type_wire_values_round_trip() { @@ -2071,6 +2436,293 @@ mod tests { assert_eq!(position.count, 42); assert_eq!(position.height, 60.0); assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity, 90); + assert_eq!(position.shadow, None); + assert_eq!(position.active_shadow, None); + } + + #[test] + fn key_position_visual_effects_round_trip_without_rewriting_missing_defaults() { + let fixture = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "rgba(10, 20, 30, 0.45)", + "offsetX": -2.0, + "offsetY": 7.0, + "blur": 18.0 + }, + "activeShadow": { + "enabled": false, + "color": "rgba(0, 0, 0, 0.32)", + "offsetX": 0.0, + "offsetY": 3.0, + "blur": 8.0 + } + }); + + let position: KeyPosition = serde_json::from_value(fixture.clone()).unwrap(); + let serialized = serde_json::to_value(position).unwrap(); + + assert_eq!(serialized.get("shadow"), fixture.get("shadow")); + assert_eq!(serialized.get("activeShadow"), fixture.get("activeShadow")); + } + + #[test] + fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "type": "linear", + "stops": [ + { "color": "c9", "pos": 1.4 }, + { "color": "c8", "pos": 0.8 }, + { "color": "c7", "pos": 0.7 }, + { "color": "c6", "pos": 0.6 }, + { "color": "c5", "pos": 0.5 }, + { "color": "c4", "pos": 0.4 }, + { "color": "c3", "pos": 0.3 }, + { "color": "c2", "pos": 0.2 }, + { "color": "c1", "pos": 0.1 }, + { "color": "c0", "pos": -0.2 } + ] + })) + .unwrap(); + + assert_eq!(gradient.angle, 90.0); + assert_eq!(gradient.stops.len(), 8); + assert_eq!(gradient.stops.first().unwrap().color, "c0"); + assert_eq!(gradient.stops.first().unwrap().pos, 0.0); + assert_eq!(gradient.stops.last().unwrap().color, "c7"); + + let canonical = serde_json::to_value(&gradient).unwrap(); + assert_eq!(canonical["angle"], 90.0); + assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); + assert!(canonical.get("type").is_none()); + + let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), canonical); + } + + #[test] + fn gradient_spec_rejects_fewer_than_two_stops() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + })) + .unwrap_err(); + + assert!(error.to_string().contains("at least two stops")); + } + + #[test] + fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": null, + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap_err(); + assert!(error.to_string().contains("invalid gradient angle")); + + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!( + gradient + .stops + .iter() + .map(|stop| stop.color.as_str()) + .collect::>(), + ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] + ); + } + + #[test] + fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { + let legacy_literals = [ + "#FFFFFF", + "#000000", + "rgba(121, 121, 121, 0.9)", + "transparent", + ]; + let visual_pairs = [ + ("#FFFFFF", "rgba(255,255,255,1)"), + ("#000000", "rgba(0,0,0,1)"), + ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), + ]; + + for (input, expected) in visual_pairs { + let escaped = compact_canonical_rgba(input); + assert_eq!(escaped, expected); + assert!(legacy_literals.iter().all(|literal| escaped != *literal)); + } + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeatureKeyPosition { + background_color: Option, + counter: PreFeatureCounterSettings, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeatureCounterSettings { + fill: KeyCounterColor, + stroke: KeyCounterColor, + placement: KeyCounterPlacement, + align: KeyCounterAlign, + align_mode: KeyCounterAlignMode, + gap: u32, + font_size: u32, + font_weight: u32, + font_family: Option, + font_italic: bool, + font_underline: bool, + font_strikethrough: bool, + } + + impl PreFeatureCounterSettings { + fn matches_legacy_migration_snapshot(&self) -> bool { + let shared = matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + let oldest = self.fill.idle == "#FFFFFF" + && self.fill.active == "#000000" + && self.stroke.idle == "#000000" + && self.stroke.active == "#FFFFFF" + && self.font_weight == 400; + let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.stroke.idle == "transparent" + && self.stroke.active == "transparent" + && self.font_weight == 700; + shared && (oldest || previous) + } + } + + #[test] + fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { + for (legacy_fill, first_stop, expected_escape) in [ + ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), + ( + "rgba(121, 121, 121, 0.9)", + "rgba(121, 121, 121, 0.9)", + "rgba(121,121,121,0.9)", + ), + ] { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "#102030", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "#102030", "pos": 0 }, + { "color": "#405060", "pos": 1 } + ] + }, + "counter": { + "enabled": true, + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": legacy_fill, + "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } + }, + "fillIdleGradient": { + "angle": 90, + "stops": [ + { "color": first_stop, "pos": 0 }, + { "color": "#654321", "pos": 1 } + ] + }, + "stroke": if legacy_fill == "#FFFFFF" { + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }) + } else { + serde_json::json!({ "idle": "transparent", "active": "transparent" }) + }, + "gap": 6, + "fontSize": 16, + "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + } + })) + .unwrap(); + + let (_, pair_repaired) = position.canonicalize_gradient_pairs(); + assert!(pair_repaired); + assert_eq!(position.counter.fill.idle, expected_escape); + assert!(!position.counter.migrate_legacy_defaults()); + + let serialized = serde_json::to_value(&position).unwrap(); + let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(shadow.background_color.as_deref(), Some("#102030")); + assert_eq!(shadow.counter.fill.idle, expected_escape); + assert!(!shadow.counter.matches_legacy_migration_snapshot()); + } + } + + #[test] + fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { + for snapshot in [ + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": { "idle": "#000000", "active": "#FFFFFF" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": "rgba(121, 121, 121, 0.9)", + "active": "#FFFFFF" + }, + "stroke": { "idle": "transparent", "active": "transparent" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 700, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + ] { + let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); + + assert!(counter.migrate_legacy_defaults()); + assert_eq!(counter, KeyCounterSettings::default()); + } } } diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index c636eac2..4e660576 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -10,7 +10,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, CustomTab, EditorCommitRequest, EditorDocumentV1, EditorField, - EditorHistoryRestoreRequest, KeyCounters, KeyMappings, KeyPosition, EDITOR_SCHEMA_VERSION, + EditorHistoryRestoreRequest, ElementShadowSpec, KeyCounters, KeyMappings, KeyPosition, + EDITOR_SCHEMA_VERSION, }, }; @@ -29,6 +30,10 @@ const MAX_GROUP_ID_BYTES: usize = 256; const MAX_GROUP_NAME_BYTES: usize = 1_024; const MAX_ABS_COORDINATE: f64 = 32_768.0; const MAX_DIMENSION: f64 = 32_768.0; +use crate::models::{ + SHADOW_BLUR_MAX as MAX_SHADOW_BLUR, SHADOW_BLUR_MIN as MIN_SHADOW_BLUR, + SHADOW_OFFSET_MAX as MAX_SHADOW_OFFSET, SHADOW_OFFSET_MIN as MIN_SHADOW_OFFSET, +}; const REQUEST_WARNING_BYTES: usize = 1_024 * 1_024; const MAX_REQUEST_BYTES: usize = 8 * 1_024 * 1_024; @@ -477,11 +482,110 @@ fn collect_violations( } } + collect_position_style_violations(document, &mut violations); let group_ids = collect_group_violations(document, &mut violations); collect_group_reference_violations(document, &group_ids, &mut violations); violations } +fn collect_position_style_violations( + document: &EditorDocumentV1, + violations: &mut BTreeSet, +) { + for (field, mode, index, position) in document + .key_positions + .iter() + .flat_map(|(mode, positions)| { + positions + .iter() + .enumerate() + .map(move |(index, position)| ("keyPositions", mode, index, position)) + }) + .chain( + document + .stat_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("statPositions", mode, index, &position.position) + }) + }), + ) + .chain( + document + .graph_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("graphPositions", mode, index, &position.position) + }) + }), + ) + .chain( + document + .knob_positions + .iter() + .flat_map(|(mode, positions)| { + positions.iter().enumerate().map(move |(index, position)| { + ("knobPositions", mode, index, &position.position) + }) + }), + ) + { + for (name, shadow) in [ + ("shadow", position.shadow.as_ref()), + ("activeShadow", position.active_shadow.as_ref()), + ] { + if let Some(shadow) = shadow { + collect_shadow_violations(field, mode, index, name, shadow, violations); + } + } + } +} + +fn collect_shadow_violations( + field: &str, + mode: &str, + index: usize, + name: &str, + shadow: &ElementShadowSpec, + violations: &mut BTreeSet, +) { + if shadow.color.is_empty() { + violations.insert(ValidationViolation::new( + format!("element-shadow:{field}:{mode}:{index}:{name}:color-empty"), + "INVALID_ELEMENT_SHADOW", + format!("{field} {mode}[{index}].{name}.color must be a non-empty string"), + )); + } + for (property, value) in [("offsetX", shadow.offset_x), ("offsetY", shadow.offset_y)] { + if !value.is_finite() || !(MIN_SHADOW_OFFSET..=MAX_SHADOW_OFFSET).contains(&value) { + violations.insert(ValidationViolation::new( + format!( + "element-shadow:{field}:{mode}:{index}:{name}:{property}:{}", + value.to_bits() + ), + "INVALID_ELEMENT_SHADOW", + format!( + "{field} {mode}[{index}].{name}.{property} must be a finite number between {MIN_SHADOW_OFFSET} and {MAX_SHADOW_OFFSET}" + ), + )); + } + } + if !shadow.blur.is_finite() || !(MIN_SHADOW_BLUR..=MAX_SHADOW_BLUR).contains(&shadow.blur) { + violations.insert(ValidationViolation::new( + format!( + "element-shadow:{field}:{mode}:{index}:{name}:blur:{}", + shadow.blur.to_bits() + ), + "INVALID_ELEMENT_SHADOW", + format!( + "{field} {mode}[{index}].{name}.blur must be a finite number between {MIN_SHADOW_BLUR} and {MAX_SHADOW_BLUR}" + ), + )); + } +} + fn collect_collection_violations( field: &'static str, collection: &HashMap>, @@ -938,8 +1042,9 @@ mod tests { use std::collections::HashMap; use crate::models::{ - CustomTab, EditorCommitRequest, EditorDocumentV1, EditorPatchV1, KeyPosition, - LayerGroupDef, StatPosition, StatType, + CustomTab, EditorCommitRequest, EditorDocumentV1, EditorPatchV1, ElementShadowSpec, + GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, + StatPosition, StatType, }; use super::*; @@ -974,6 +1079,61 @@ mod tests { store } + fn store_with_each_position_collection() -> AppStoreData { + let mut store = default_editor_store(); + store.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + ); + store.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition::default(), + }], + ); + store.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: String::new(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }], + ); + store + } + + fn position_mut<'a>( + document: &'a mut EditorDocumentV1, + collection: &str, + ) -> &'a mut KeyPosition { + match collection { + "keyPositions" => &mut document.key_positions.get_mut("4key").unwrap()[0], + "statPositions" => &mut document.stat_positions.get_mut("4key").unwrap()[0].position, + "graphPositions" => &mut document.graph_positions.get_mut("4key").unwrap()[0].position, + "knobPositions" => &mut document.knob_positions.get_mut("4key").unwrap()[0].position, + _ => unreachable!(), + } + } + + fn valid_shadow() -> ElementShadowSpec { + ElementShadowSpec { + enabled: true, + color: "#123456".to_string(), + offset_x: 0.0, + offset_y: 0.0, + blur: 12.0, + } + } + #[test] fn canonical_fingerprint_ignores_hash_map_insertion_order() { let mut left = HashMap::new(); @@ -1159,6 +1319,104 @@ mod tests { ); } + #[test] + fn editor_rejects_new_shadow_violations_in_every_position_collection() { + let store = store_with_each_position_collection(); + let current = EditorDocumentV1::from_store(&store); + + for (collection, active, property, expected_path) in [ + ( + "keyPositions", + false, + "blur", + "keyPositions 4key[0].shadow.blur", + ), + ( + "statPositions", + true, + "offsetX", + "statPositions 4key[0].activeShadow.offsetX", + ), + ( + "graphPositions", + false, + "offsetY", + "graphPositions 4key[0].shadow.offsetY", + ), + ( + "knobPositions", + true, + "color", + "knobPositions 4key[0].activeShadow.color", + ), + ] { + let mut candidate = current.clone(); + let mut shadow = valid_shadow(); + match property { + "blur" => shadow.blur = MAX_SHADOW_BLUR + 0.1, + "offsetX" => shadow.offset_x = MIN_SHADOW_OFFSET - 0.1, + "offsetY" => shadow.offset_y = MAX_SHADOW_OFFSET + 0.1, + "color" => shadow.color.clear(), + _ => unreachable!(), + } + let position = position_mut(&mut candidate, collection); + if active { + position.active_shadow = Some(shadow); + } else { + position.shadow = Some(shadow); + } + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + let error = + validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("INVALID_ELEMENT_SHADOW") + ); + assert!(error.message.contains(expected_path)); + } + } + + #[test] + fn existing_shadow_violations_are_grandfathered_only_when_unchanged() { + let mut store = default_editor_store(); + let position = &mut store.key_positions.get_mut("4key").unwrap()[0]; + let mut shadow = valid_shadow(); + shadow.blur = MAX_SHADOW_BLUR + 1.0; + position.shadow = Some(shadow); + let current = EditorDocumentV1::from_store(&store); + + let mut unrelated = current.clone(); + unrelated.key_positions.get_mut("4key").unwrap()[0].font_size = Some(18.0); + let mut unrelated_store = store.clone(); + unrelated.apply_to_store(&mut unrelated_store); + validate_document_transition(¤t, &unrelated, &store, &unrelated_store).unwrap(); + + let mut changed_shadow = current.clone(); + changed_shadow.key_positions.get_mut("4key").unwrap()[0] + .shadow + .as_mut() + .unwrap() + .blur += 1.0; + let mut changed_shadow_store = store.clone(); + changed_shadow.apply_to_store(&mut changed_shadow_store); + let shadow_error = + validate_document_transition(¤t, &changed_shadow, &store, &changed_shadow_store) + .unwrap_err(); + assert_eq!( + shadow_error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("INVALID_ELEMENT_SHADOW") + ); + } + #[test] fn oversized_per_mode_collection_is_grandfathered_only_when_non_increasing() { let mut store = store_with_custom_modes(1); diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 7420ff22..b6d9b6c2 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -18,8 +18,8 @@ use crate::{ defaults::{default_keys, default_positions}, models::{ AppStoreData, CounterAnimationPreset, CustomCss, CustomFont, CustomJs, CustomTab, FontType, - GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, JsPlugin, - KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, + GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, + JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, OverlayBounds, ShortcutsState, SoundLibraryEntry, StatPosition, StatPositions, StatType, TabCss, TabNoteSettings, }, @@ -97,12 +97,18 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { value.get("keyPositions"), ); needs_persist |= layout_repaired; + let (gradient_changed, gradient_pair_repaired) = + canonicalize_gradient_pairs(&mut data); + needs_persist |= gradient_changed; needs_persist |= key_position_lengths_mismatch(&data.keys, &data.key_positions); needs_persist |= !has_valid_selected_key_type(&data); ( normalize_state(data), needs_persist, - layout_repaired || semantic_repaired || editor_revision_repaired, + layout_repaired + || semantic_repaired + || editor_revision_repaired + || gradient_pair_repaired, ) } Err(err) => { @@ -836,6 +842,37 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +pub(crate) fn canonicalize_gradient_pairs(data: &mut AppStoreData) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + for position in data.key_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for stat in data.stat_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + stat.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for graph in data.graph_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + graph.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for knob in data.knob_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + knob.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + + (changed, pair_repaired) +} + fn repair_editor_revision(data: &mut AppStoreData) -> bool { if data.editor_revision <= super::editor::MAX_SAFE_EDITOR_REVISION { return false; @@ -1056,6 +1093,7 @@ fn repair_legacy_state(value: Value) -> AppStoreData { source_keys.as_ref(), source_key_positions.as_ref(), ); + canonicalize_gradient_pairs(&mut data); normalize_state(data) } @@ -1383,15 +1421,23 @@ where continue; } - if !has_valid_identity(entry) { + let entry_name = format!("{field}.{mode}[{index}]"); + let mut candidate = entry.clone(); + recover_invalid_counter_gradient_children(&entry_name, &mut candidate); + if serde_json::from_value::(candidate.clone()).is_ok() { + recovered_entries.push(candidate); + continue; + } + + if !has_valid_identity(&candidate) { log::warn!( "[Store] Removing invalid {field} entry '{mode}[{index}]' with a damaged identity during recovery" ); continue; } - let entry_name = format!("{field}.{mode}[{index}]"); - let Some(partial) = recover_object_fields::(&entry_name, entry) else { + let Some(partial) = recover_object_fields::(&entry_name, &candidate) + else { log::warn!( "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery" ); @@ -1460,7 +1506,14 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { Ok(_) => recovered_entries.push(entry.clone()), Err(err) => { let entry_name = format!("{field}.{mode}[{index}]"); - let recovered = recover_object_fields::(&entry_name, entry) + let mut candidate = entry.clone(); + recover_invalid_counter_gradient_children(&entry_name, &mut candidate); + let recovered = if serde_json::from_value::(candidate.clone()) + .is_ok() + { + candidate + } else { + recover_object_fields::(&entry_name, &candidate) .filter(|candidate| { serde_json::from_value::(candidate.clone()).is_ok() }) @@ -1469,7 +1522,8 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { "[Store] Replacing invalid {field} entry '{mode}[{index}]' with default during recovery: {err}" ); default_position.clone() - }); + }) + }; recovered_entries.push(recovered); } } @@ -1479,6 +1533,27 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { Some(Value::Object(recovered_modes)) } +fn recover_invalid_counter_gradient_children(entry_name: &str, value: &mut Value) -> bool { + let Some(counter) = value.get_mut("counter").and_then(Value::as_object_mut) else { + return false; + }; + + let mut changed = false; + for field in ["fillIdleGradient", "fillActiveGradient"] { + let invalid = counter.get(field).is_some_and(|gradient| { + serde_json::from_value::>(gradient.clone()).is_err() + }); + if invalid { + log::warn!( + "[Store] Resetting invalid {entry_name}.counter.{field} to None during recovery" + ); + counter.remove(field); + changed = true; + } + } + changed +} + fn recover_position_entries(field: &str, value: &Value) -> Option where T: DeserializeOwned, @@ -1749,6 +1824,7 @@ mod tests { defaults::{default_keys, default_positions}, models::{ AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, + KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyPosition, KnobPosition, LayerGroupDef, OverlayBounds, SoundLibraryEntry, StatPosition, StatType, TabCss, TabNoteSettings, }, @@ -1826,6 +1902,158 @@ mod tests { loaded.data } + #[test] + fn legacy_store_without_gradient_fields_preserves_position_bytes() { + let path = std::env::temp_dir().join(format!( + "dmnote-no-gradient-byte-round-trip-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let original_position = serde_json::to_vec_pretty(&data.key_positions["4key"][0]).unwrap(); + let original = serde_json::to_vec_pretty(&data).unwrap(); + assert!(!String::from_utf8_lossy(&original).contains("Gradient")); + std::fs::write(&path, &original).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let reserialized_position = + serde_json::to_vec_pretty(&loaded.data.key_positions["4key"][0]).unwrap(); + + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!(reserialized_position, original_position); + let _ = std::fs::remove_file(path); + } + + #[test] + fn noncanonical_gradient_store_repersist_and_reload_is_idempotent() { + let path = std::env::temp_dir().join(format!( + "dmnote-gradient-canonical-reload-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let mut raw = serde_json::to_value(data).unwrap(); + let position = &mut raw["keyPositions"]["4key"][0]; + position["backgroundColor"] = serde_json::json!("#BADBAD"); + position["backgroundGradient"] = serde_json::json!({ + "type": "linear", + "angle": 450, + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } + ] + }); + position["counter"]["fill"]["idle"] = serde_json::json!("#FFFFFF"); + position["counter"]["fillIdleGradient"] = serde_json::json!({ + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let position = &loaded.data.key_positions["4key"][0]; + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!( + position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!(position.background_gradient.as_ref().unwrap().angle, 90.0); + assert_eq!( + position.background_gradient.as_ref().unwrap().stops[0].pos, + 0.0 + ); + assert_eq!(position.counter.fill.idle, "rgba(255,255,255,1)"); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data, loaded.data); + let _ = std::fs::remove_file(path); + } + + #[test] + fn invalid_counter_gradient_children_recover_without_losing_counter_siblings() { + let path = std::env::temp_dir().join(format!( + "dmnote-counter-gradient-child-recovery-{}.json", + uuid::Uuid::new_v4() + )); + let mut key_position = default_positions()["4key"][0].clone(); + key_position.counter.enabled = false; + key_position.counter.placement = KeyCounterPlacement::Outside; + key_position.counter.align = KeyCounterAlign::Left; + key_position.counter.align_mode = KeyCounterAlignMode::Between; + key_position.counter.fill = KeyCounterColor { + idle: "#112233".to_string(), + active: "#445566".to_string(), + }; + key_position.counter.stroke = KeyCounterColor { + idle: "#778899".to_string(), + active: "#AABBCC".to_string(), + }; + key_position.counter.gap = 17; + key_position.counter.font_size = 33; + key_position.counter.font_weight = 600; + key_position.counter.font_family = Some("Recovery Font".to_string()); + key_position.counter.font_italic = true; + key_position.counter.font_underline = true; + key_position.counter.font_strikethrough = true; + key_position.counter.animation.enabled = true; + key_position.counter.animation.preset_id = Some("custom-recovery".to_string()); + key_position.counter.animation.bezier = [0.1, 0.2, 0.7, 0.8]; + key_position.counter.animation.scale = 1.25; + key_position.counter.animation.duration_ms = 777; + let expected_key_counter = key_position.counter.clone(); + + let mut stat_position = StatPosition { + stat_type: StatType::Kps, + position: key_position.clone(), + }; + stat_position.position.counter.fill.idle = "#ABCDEF".to_string(); + let expected_stat_counter = stat_position.position.counter.clone(); + + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.key_positions.get_mut("4key").unwrap()[0] = key_position; + data.stat_positions + .insert("4key".to_string(), vec![stat_position]); + let mut raw = serde_json::to_value(data).unwrap(); + raw["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }); + raw["statPositions"]["4key"][0]["counter"]["fillActiveGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#000000", "pos": 1 }] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let key_counter = &loaded.data.key_positions["4key"][0].counter; + let stat_counter = &loaded.data.stat_positions["4key"][0].position.counter; + + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!(key_counter, &expected_key_counter); + assert_eq!(stat_counter, &expected_stat_counter); + assert!(key_counter.fill_idle_gradient.is_none()); + assert!(stat_counter.fill_active_gradient.is_none()); + let _ = std::fs::remove_file(path); + } + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct HistoricalTauri13Store { diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 89bff6cd..97037436 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -454,6 +454,11 @@ impl AppStore { let mut candidate = current.clone(); candidate.apply_patch(&request.changes); + let mut scratch = current_store.clone(); + candidate.apply_to_store(&mut scratch); + crate::state::migration::canonicalize_gradient_pairs(&mut scratch); + candidate = EditorDocumentV1::from_store(&scratch); + validate_paired_update( ¤t, &candidate, @@ -461,8 +466,6 @@ impl AppStore { request.changes.includes(EditorField::KeyPositions), )?; - let mut scratch = current_store.clone(); - candidate.apply_to_store(&mut scratch); scratch.editor_revision = current_store.editor_revision; validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; @@ -575,6 +578,7 @@ impl AppStore { let current = EditorDocumentV1::from_store(¤t_store); let mut scratch = current_store.clone(); let value = updater(&mut scratch)?; + crate::state::migration::canonicalize_gradient_pairs(&mut scratch); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; @@ -2203,6 +2207,9 @@ fn sweep_unreferenced_asset_files( Ok(()) } +#[cfg(test)] +mod gradient_real_data_simulation; + #[cfg(test)] mod tests { use super::{ @@ -2380,6 +2387,205 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn editor_commit_gradient_repair_keeps_disk_document_and_event_identical() { + let dir = test_directory("editor-gradient-canonicalization-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.editor_get().document.key_positions; + let position = &mut positions.get_mut("4key").unwrap()[0]; + position.background_color = Some("#BADBAD".to_string()); + position.background_gradient = Some( + serde_json::from_value(json!({ + "type": "linear", + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.2 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.1 } + ] + })) + .unwrap(), + ); + position.counter.fill.idle = "#FFFFFF".to_string(); + position.counter.fill_idle_gradient = Some( + serde_json::from_value(json!({ + "angle": 450, + "stops": [ + { "color": "#FFFFFF80", "pos": 0 }, + { "color": "rgba(0, 0, 0, 0)", "pos": 1 } + ] + })) + .unwrap(), + ); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }, + ); + + let change = store.commit_editor_document(request).unwrap(); + let committed_position = &change.document.key_positions["4key"][0]; + assert_eq!( + change.result.changed_fields, + vec![EditorField::KeyPositions] + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .angle, + 90.0 + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .stops[0] + .pos, + 0.0 + ); + assert_eq!( + committed_position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!( + committed_position.counter.fill.idle, + "rgba(255,255,255,0.502)" + ); + + let event_positions = change + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap(); + assert_eq!(event_positions, &change.document.key_positions); + assert_eq!( + EditorDocumentV1::from_store(&store.snapshot()), + change.document + ); + + let committed_document = change.document; + store.flush_and_shutdown().unwrap(); + drop(store); + let reloaded = + crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!( + EditorDocumentV1::from_store(&reloaded.data), + committed_document + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_gradient_preset_parse_failure_leaves_store_unchanged() { + let dir = test_directory("invalid-gradient-preset-atomicity-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + + let parsed = serde_json::from_value::(json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundGradient": { + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + } + }] + } + })); + + assert!(parsed.is_err()); + assert_eq!(store.snapshot(), before); + assert_eq!(store.writer.persist_count(), persist_count); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_shadow_preset_read_failure_leaves_store_unchanged() { + let dir = test_directory("invalid-shadow-preset-atomicity-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + let source_path = dir.join("invalid-preset.json"); + + for (source, expected_error) in [ + ( + r##"{ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 0, + "blur": 100.1 + } + }] + } + }"##, + "invalid-preset: keyPositions[\"4key\"][0].shadow.blur: must be a finite number between 0 and 100", + ), + ( + r##"{ + "statPositions": { + "4key": [{ + "statType": "kps", + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "activeShadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100.1, + "offsetY": 0, + "blur": 12 + } + }] + } + }"##, + "invalid-preset: statPositions[\"4key\"][0].activeShadow.offsetX: must be a finite number between -100 and 100", + ), + ] { + std::fs::write(&source_path, source).unwrap(); + let error = crate::commands::preset::load::read_preset_file_for_simulation( + &source_path, + ) + .err() + .expect("invalid visual effect preset must be rejected") + .to_string(); + + assert_eq!(error, expected_error); + assert_eq!(store.snapshot(), before); + assert_eq!(store.writer.persist_count(), persist_count); + } + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn strict_editor_noop_is_acknowledged_without_persist_or_event() { let dir = test_directory("strict-editor-noop-test"); diff --git a/src-tauri/src/state/store/gradient_real_data_simulation.rs b/src-tauri/src/state/store/gradient_real_data_simulation.rs new file mode 100644 index 00000000..17cb1d9d --- /dev/null +++ b/src-tauri/src/state/store/gradient_real_data_simulation.rs @@ -0,0 +1,755 @@ +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use super::AppStore; +use crate::{ + commands::preset::{ + load::read_preset_file_for_simulation, save::write_preset_file_for_simulation, PresetFile, + }, + defaults::{default_keys, default_positions}, + models::{AppStoreData, EditorCommitOrigin, EditorField, GradientSpec, KeyPosition}, + state::migration::{canonicalize_gradient_pairs, load_store_from_path, normalize_state}, +}; + +const GRADIENT_FIELDS: [&str; 6] = [ + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + "fillIdleGradient", + "fillActiveGradient", +]; + +struct RealFixture { + source_path: PathBuf, + bytes: Vec, + digest: Vec, +} + +impl RealFixture { + fn from_env(simulation: u8) -> Self { + let source_path = std::env::var_os("DMNOTE_SIM_STORE_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| { + panic!("DMNOTE_SIM_STORE_PATH must be set to run ignored simulation {simulation}") + }); + let bytes = fs::read(&source_path).unwrap_or_else(|error| { + panic!("failed to read DMNOTE_SIM_STORE_PATH for simulation {simulation}: {error}") + }); + assert!( + !bytes.is_empty(), + "DMNOTE_SIM_STORE_PATH must point to a non-empty store" + ); + let digest = Sha256::digest(&bytes).to_vec(); + Self { + source_path, + bytes, + digest, + } + } + + fn verify_unchanged(&self) { + let current = fs::read(&self.source_path) + .expect("the source fixture must remain readable after the simulation"); + assert_eq!( + Sha256::digest(¤t).to_vec(), + self.digest, + "the source fixture changed during the simulation" + ); + } +} + +struct SimulationDir { + path: PathBuf, +} + +impl SimulationDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "dmnote-gradient-real-data-{label}-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("simulation temp directory must be creatable"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for SimulationDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[derive(Clone)] +struct PositionLocation { + mode: String, + index: usize, +} + +fn write_store_copy(directory: &SimulationDir, name: &str, bytes: &[u8]) -> PathBuf { + let path = directory.path().join(name); + fs::write(&path, bytes).expect("simulation store copy must be writable"); + path +} + +fn first_position_location(value: &Value) -> PositionLocation { + let modes = value + .get("keyPositions") + .and_then(Value::as_object) + .expect("real store must contain a keyPositions object"); + for (mode, positions) in modes { + if positions + .as_array() + .is_some_and(|positions| !positions.is_empty()) + { + return PositionLocation { + mode: mode.clone(), + index: 0, + }; + } + } + panic!("real store must contain at least one key position"); +} + +fn position_object_mut<'a>( + value: &'a mut Value, + location: &PositionLocation, +) -> &'a mut Map { + value + .get_mut("keyPositions") + .and_then(Value::as_object_mut) + .and_then(|modes| modes.get_mut(&location.mode)) + .and_then(Value::as_array_mut) + .and_then(|positions| positions.get_mut(location.index)) + .and_then(Value::as_object_mut) + .expect("selected key position must remain an object") +} + +fn position_from_store<'a>(data: &'a AppStoreData, location: &PositionLocation) -> &'a KeyPosition { + data.key_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .expect("selected key position must survive the simulation") +} + +fn serialized_position(data: &AppStoreData, location: &PositionLocation) -> Value { + serde_json::to_value(position_from_store(data, location)) + .expect("key position must remain serializable") +} + +fn contains_gradient_fields(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(contains_gradient_fields), + Value::Object(fields) => fields.iter().any(|(name, value)| { + GRADIENT_FIELDS.contains(&name.as_str()) || contains_gradient_fields(value) + }), + _ => false, + } +} + +fn collect_counter_colors(value: &Value, colors: &mut Vec) { + match value { + Value::Array(values) => { + for value in values { + collect_counter_colors(value, colors); + } + } + Value::Object(fields) => { + if let Some(counter) = fields.get("counter").and_then(Value::as_object) { + if let Some(fill) = counter.get("fill") { + colors.push(fill.clone()); + } + if let Some(stroke) = counter.get("stroke") { + colors.push(stroke.clone()); + } + } + for value in fields.values() { + collect_counter_colors(value, colors); + } + } + _ => {} + } +} + +fn counter_colors(value: &Value) -> Vec { + let mut colors = Vec::new(); + for field in [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ] { + if let Some(collection) = value.get(field) { + collect_counter_colors(collection, &mut colors); + } + } + colors +} + +fn collect_changed_paths(left: &Value, right: &Value, path: &str, changed: &mut BTreeSet) { + match (left, right) { + (Value::Object(left), Value::Object(right)) => { + for field in left.keys().chain(right.keys()) { + let next_path = if path.is_empty() { + field.clone() + } else { + format!("{path}.{field}") + }; + match (left.get(field), right.get(field)) { + (Some(left), Some(right)) => { + collect_changed_paths(left, right, &next_path, changed); + } + _ => { + changed.insert(next_path); + } + } + } + } + (Value::Array(left), Value::Array(right)) => { + if left.len() != right.len() { + changed.insert(format!("{path}.")); + } + for (left, right) in left.iter().zip(right) { + collect_changed_paths(left, right, path, changed); + } + } + _ if left != right => { + changed.insert(path.to_string()); + } + _ => {} + } +} + +fn changed_key_position_fields(left: &Value, right: &Value) -> BTreeSet { + let mut changed = BTreeSet::new(); + let Some(left_modes) = left.as_object() else { + changed.insert("".to_string()); + return changed; + }; + let Some(right_modes) = right.as_object() else { + changed.insert("".to_string()); + return changed; + }; + for mode in left_modes.keys().chain(right_modes.keys()) { + match (left_modes.get(mode), right_modes.get(mode)) { + (Some(Value::Array(left)), Some(Value::Array(right))) => { + if left.len() != right.len() { + changed.insert("".to_string()); + } + for (left, right) in left.iter().zip(right) { + collect_changed_paths(left, right, "", &mut changed); + } + } + _ => { + changed.insert("".to_string()); + } + } + } + changed +} + +fn editor_preset_from_store(data: &AppStoreData) -> PresetFile { + PresetFile { + keys: Some(data.keys.clone()), + key_positions: Some(data.key_positions.clone()), + stat_positions: Some(data.stat_positions.clone()), + graph_positions: Some(data.graph_positions.clone()), + knob_positions: Some(data.knob_positions.clone()), + custom_tabs: Some(data.custom_tabs.clone()), + selected_key_type: Some(data.selected_key_type.clone()), + layer_groups: Some(data.layer_groups.clone()), + ..PresetFile::default() + } +} + +fn import_editor_preset(directory: &Path, preset: PresetFile) -> AppStoreData { + fs::create_dir_all(directory).expect("preset import directory must be creatable"); + let seed = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + fs::write( + directory.join("store.json"), + serde_json::to_vec_pretty(&seed).expect("seed store must be serializable"), + ) + .expect("seed store must be writable"); + + let store = + AppStore::initialize_in_dir(directory).expect("preset import store must initialize"); + let current = store.snapshot(); + let keys = preset.keys.unwrap_or_else(|| current.keys.clone()); + let key_positions = preset + .key_positions + .unwrap_or_else(|| current.key_positions.clone()); + let stat_positions = preset + .stat_positions + .unwrap_or_else(|| current.stat_positions.clone()); + let graph_positions = preset + .graph_positions + .unwrap_or_else(|| current.graph_positions.clone()); + let knob_positions = preset + .knob_positions + .unwrap_or_else(|| current.knob_positions.clone()); + let layer_groups = preset + .layer_groups + .unwrap_or_else(|| current.layer_groups.clone()); + let custom_tabs = preset + .custom_tabs + .unwrap_or_else(|| current.custom_tabs.clone()); + let selected_key_type = preset + .selected_key_type + .unwrap_or_else(|| current.selected_key_type.clone()); + + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("gradient_simulation_preset_import".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + move |data| { + data.keys = keys; + data.key_positions = key_positions; + data.stat_positions = stat_positions; + data.graph_positions = graph_positions; + data.knob_positions = knob_positions; + data.layer_groups = layer_groups; + data.custom_tabs = custom_tabs; + data.selected_key_type = selected_key_type; + Ok(()) + }, + ) + .expect("preset editor collections must import atomically"); + + let committed = store.snapshot(); + store + .flush_and_shutdown() + .expect("imported preset must flush"); + drop(store); + + let reloaded = load_store_from_path(&directory.join("store.json")) + .expect("imported preset store must reload"); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data.editor_revision, committed.editor_revision); + reloaded.data +} + +fn set_damage_control_values(position: &mut Map) { + position.insert("backgroundColor".to_string(), json!("#101010")); + position.insert("activeBackgroundColor".to_string(), json!("#202020")); + position.insert("borderColor".to_string(), json!("#303030")); + position.insert("activeBorderColor".to_string(), json!("#404040")); + + let counter = position + .get_mut("counter") + .and_then(Value::as_object_mut) + .expect("serialized key position must contain a counter object"); + let fill = counter + .get_mut("fill") + .and_then(Value::as_object_mut) + .expect("serialized counter must contain fill colors"); + fill.insert("idle".to_string(), json!("#505050")); + fill.insert("active".to_string(), json!("#606060")); + let stroke = counter + .get_mut("stroke") + .and_then(Value::as_object_mut) + .expect("serialized counter must contain stroke colors"); + stroke.insert("idle".to_string(), json!("#707070")); + stroke.insert("active".to_string(), json!("#808080")); +} + +// 실행: DMNOTE_SIM_STORE_PATH=/path/to/store.json cargo test -- --ignored +#[test] +#[ignore] +fn simulation_1_real_legacy_store_load_is_gradient_neutral() { + let fixture = RealFixture::from_env(1); + let directory = SimulationDir::new("legacy-load"); + let path = write_store_copy(&directory, "store.json", &fixture.bytes); + + let directly_parsed: AppStoreData = serde_json::from_slice(&fixture.bytes) + .expect("real store must deserialize without collection recovery"); + let mut gradient_probe = directly_parsed.clone(); + let (gradient_changed, gradient_pair_repaired) = + canonicalize_gradient_pairs(&mut gradient_probe); + assert!(!gradient_changed); + assert!(!gradient_pair_repaired); + + let loaded = load_store_from_path(&path).expect("real store must load through migration path"); + assert!(!loaded.repaired); + fixture.verify_unchanged(); + + println!( + "SIMULATION 1 PASS: directParse=true recover=false gradientNeedsPersist=false gradientRepaired=false aggregateNeedsPersist={} aggregateRepaired=false", + loaded.needs_persist + ); +} + +// 실행: DMNOTE_SIM_STORE_PATH=/path/to/store.json cargo test -- --ignored +#[test] +#[ignore] +fn simulation_2_real_solid_store_round_trip_is_lossless() { + let fixture = RealFixture::from_env(2); + let directory = SimulationDir::new("solid-round-trip"); + let path = write_store_copy(&directory, "store.json", &fixture.bytes); + let source: Value = + serde_json::from_slice(&fixture.bytes).expect("real store JSON must be readable"); + assert!(!contains_gradient_fields(&source)); + + let loaded = load_store_from_path(&path).expect("solid real store must load"); + let round_trip = + serde_json::to_value(&loaded.data).expect("loaded real store must reserialize"); + let keys_equal = source.get("keys") == round_trip.get("keys"); + let positions_equal = source.get("keyPositions") == round_trip.get("keyPositions"); + let counter_colors_equal = counter_colors(&source) == counter_colors(&round_trip); + let gradients_absent = !contains_gradient_fields(&round_trip); + let changed_fields = changed_key_position_fields( + source.get("keyPositions").unwrap(), + round_trip.get("keyPositions").unwrap(), + ); + + let keys_byte_equal = serde_json::to_vec(source.get("keys").unwrap()).unwrap() + == serde_json::to_vec(round_trip.get("keys").unwrap()).unwrap(); + let positions_byte_equal = serde_json::to_vec(source.get("keyPositions").unwrap()).unwrap() + == serde_json::to_vec(round_trip.get("keyPositions").unwrap()).unwrap(); + let full_store_byte_equal = serde_json::to_vec_pretty(&loaded.data) + .expect("loaded store must serialize") + == fixture.bytes; + fixture.verify_unchanged(); + + println!( + "SIMULATION 2 RESULT: keysEqual={keys_equal} keyPositionsEqual={positions_equal} counterColorsEqual={counter_colors_equal} gradientsAbsent={gradients_absent} keysBytes={keys_byte_equal} keyPositionsBytes={positions_byte_equal} fullStoreBytes={full_store_byte_equal} changedFields={changed_fields:?}" + ); + assert!(keys_equal, "keys changed during solid round trip"); + assert!( + positions_equal, + "keyPositions changed during solid round trip: {changed_fields:?}" + ); + assert!( + counter_colors_equal, + "counter colors changed during solid round trip" + ); + assert!(gradients_absent, "gradient siblings were generated"); + assert!(keys_byte_equal); + assert!(positions_byte_equal); +} + +// 실행: DMNOTE_SIM_STORE_PATH=/path/to/store.json cargo test -- --ignored +#[test] +#[ignore] +fn simulation_3_gradient_store_and_preset_chain_stays_canonical() { + let fixture = RealFixture::from_env(3); + let directory = SimulationDir::new("gradient-chain"); + let mut raw: Value = + serde_json::from_slice(&fixture.bytes).expect("real store JSON must be readable"); + let location = first_position_location(&raw); + let position = position_object_mut(&mut raw, &location); + position.insert("backgroundColor".to_string(), json!("#BADBAD")); + position.insert( + "backgroundGradient".to_string(), + json!({ + "type": "linear", + "angle": 450, + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } + ] + }), + ); + let counter = position + .get_mut("counter") + .and_then(Value::as_object_mut) + .expect("real key position must contain counter settings"); + counter + .get_mut("fill") + .and_then(Value::as_object_mut) + .expect("real counter must contain fill colors") + .insert("idle".to_string(), json!("#BADBAD")); + counter.insert( + "fillIdleGradient".to_string(), + json!({ + "angle": -90, + "stops": [ + { "color": "#000000", "pos": 1.5 }, + { "color": "#FFFFFF80", "pos": -0.5 } + ] + }), + ); + + let injected_path = write_store_copy( + &directory, + "store-injected.json", + &serde_json::to_vec_pretty(&raw).unwrap(), + ); + let loaded = load_store_from_path(&injected_path) + .expect("noncanonical gradient store must load and repair"); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let canonical_position = position_from_store(&loaded.data, &location); + let background = canonical_position + .background_gradient + .as_ref() + .expect("background gradient must survive"); + assert_eq!(background.angle, 90.0); + assert_eq!(background.stops[0].pos, 0.0); + assert_eq!(background.stops[1].pos, 1.0); + assert_eq!(background.stops[0].color, "rgba(139, 92, 246, 1)"); + assert_eq!( + canonical_position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + let counter_gradient = canonical_position + .counter + .fill_idle_gradient + .as_ref() + .expect("counter gradient must survive"); + assert_eq!(counter_gradient.angle, 270.0); + assert_eq!(counter_gradient.stops[0].pos, 0.0); + assert_eq!(counter_gradient.stops[1].pos, 1.0); + assert_eq!( + canonical_position.counter.fill.idle, + "rgba(255,255,255,0.502)" + ); + + let canonical_path = write_store_copy( + &directory, + "store-canonical.json", + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path) + .expect("canonical gradient store must reload idempotently"); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert!(reloaded.data == loaded.data); + + let preset_path = directory.path().join("preset.json"); + let preset = editor_preset_from_store(&reloaded.data); + write_preset_file_for_simulation(&preset_path, &preset) + .expect("canonical store must export as a preset"); + let imported_preset = read_preset_file_for_simulation(&preset_path) + .expect("exported preset must pass the import parser"); + let imported_store = import_editor_preset(&directory.path().join("imported"), imported_preset); + assert!(imported_store.keys == reloaded.data.keys); + assert!(imported_store.key_positions == reloaded.data.key_positions); + assert!(imported_store.stat_positions == reloaded.data.stat_positions); + assert!(imported_store.graph_positions == reloaded.data.graph_positions); + assert!(imported_store.knob_positions == reloaded.data.knob_positions); + fixture.verify_unchanged(); + + println!( + "SIMULATION 3 PASS: loadRepair=true reloadIdempotent=true presetExportImport=true angleAndStopsCanonical=true" + ); +} + +// 실행: DMNOTE_SIM_STORE_PATH=/path/to/store.json cargo test -- --ignored +#[test] +#[ignore] +fn simulation_4_damaged_gradient_fields_are_isolated() { + let fixture = RealFixture::from_env(4); + let directory = SimulationDir::new("damaged-fields"); + let source_path = write_store_copy(&directory, "source.json", &fixture.bytes); + let source = load_store_from_path(&source_path).expect("real store baseline must load"); + assert!(!source.repaired); + let baseline_path = write_store_copy( + &directory, + "baseline.json", + &serde_json::to_vec_pretty(&source.data).unwrap(), + ); + let baseline = load_store_from_path(&baseline_path) + .expect("persisted real store baseline must reload idempotently"); + assert!(!baseline.needs_persist); + assert!(!baseline.repaired); + let baseline_value = serde_json::to_value(&baseline.data).unwrap(); + let location = first_position_location(&baseline_value); + + for (label, target_field, damage) in [ + ( + "wrong-type", + "backgroundGradient", + json!(["not", "an", "object"]), + ), + ( + "one-stop", + "fillIdleGradient", + json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }), + ), + ( + "string-angle", + "borderGradient", + json!({ + "angle": "90", + "stops": [ + { "color": "#111111", "pos": 0 }, + { "color": "#222222", "pos": 1 } + ] + }), + ), + ] { + let mut damaged = baseline_value.clone(); + let position = position_object_mut(&mut damaged, &location); + set_damage_control_values(position); + let expected = Value::Object(position.clone()); + if target_field == "fillIdleGradient" { + position + .get_mut("counter") + .and_then(Value::as_object_mut) + .unwrap() + .insert(target_field.to_string(), damage); + } else { + position.insert(target_field.to_string(), damage); + } + + let damaged_path = write_store_copy( + &directory, + &format!("damaged-{label}.json"), + &serde_json::to_vec_pretty(&damaged).unwrap(), + ); + let loaded = load_store_from_path(&damaged_path) + .unwrap_or_else(|error| panic!("{label} gradient damage must recover: {error}")); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert!( + serialized_position(&loaded.data, &location) == expected, + "{label} damage changed fields outside its target" + ); + + let canonical_path = write_store_copy( + &directory, + &format!("canonical-{label}.json"), + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + println!("SIMULATION 4 CASE {label} PASS: action=drop siblingsPreserved=true"); + } + + let mut damaged = baseline_value; + let position = position_object_mut(&mut damaged, &location); + set_damage_control_values(position); + position.insert("activeBorderColor".to_string(), json!("#111111")); + let input_gradient = json!({ + "angle": 90, + "stops": [ + { "color": "#222222", "pos": 1.4 }, + { "color": "#111111", "pos": -0.4 } + ] + }); + position.insert("activeBorderGradient".to_string(), input_gradient.clone()); + let mut expected = Value::Object(position.clone()); + let canonical_gradient: GradientSpec = serde_json::from_value(input_gradient).unwrap(); + expected.as_object_mut().unwrap().insert( + "activeBorderGradient".to_string(), + serde_json::to_value(canonical_gradient).unwrap(), + ); + + let damaged_path = write_store_copy( + &directory, + "damaged-out-of-range.json", + &serde_json::to_vec_pretty(&damaged).unwrap(), + ); + let loaded = load_store_from_path(&damaged_path).expect("out-of-range stops must canonicalize"); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + assert!( + serialized_position(&loaded.data, &location) == expected, + "out-of-range repair changed fields outside its pair" + ); + let gradient = position_from_store(&loaded.data, &location) + .active_border_gradient + .as_ref() + .unwrap(); + assert_eq!(gradient.stops[0].pos, 0.0); + assert_eq!(gradient.stops[1].pos, 1.0); + + let canonical_path = write_store_copy( + &directory, + "canonical-out-of-range.json", + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + fixture.verify_unchanged(); + + println!("SIMULATION 4 CASE out-of-range PASS: action=canonicalize siblingsPreserved=true"); + println!("SIMULATION 4 PASS: damageCases=4 isolated=true reloadIdempotent=true"); +} + +#[test] +fn simulation_5_inline_legacy_preset_imports_without_gradients() { + const LEGACY_PRESET: &str = r##"{ + "keys": { + "4key": ["KeyQ"], + "5key": ["KeyW"], + "6key": ["KeyE"], + "8key": ["KeyR"] + }, + "keyPositions": { + "4key": [{ + "dx": 12, + "dy": 34, + "width": 58, + "count": 7, + "backgroundColor": "#123456", + "activeBackgroundColor": "#234567", + "borderColor": "#345678", + "activeBorderColor": "#456789", + "counter": { + "fill": { "idle": "#ABCDEF", "active": "#FEDCBA" }, + "stroke": { "idle": "#111111", "active": "#222222" } + } + }], + "5key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }], + "6key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }], + "8key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }] + }, + "selectedKeyType": "4key" + }"##; + let legacy_value: Value = serde_json::from_str(LEGACY_PRESET).unwrap(); + assert!(!contains_gradient_fields(&legacy_value)); + + let directory = SimulationDir::new("legacy-preset"); + let preset_path = directory.path().join("legacy-preset.json"); + fs::write(&preset_path, LEGACY_PRESET).unwrap(); + let preset = read_preset_file_for_simulation(&preset_path) + .expect("gradient-free legacy preset must pass the import parser"); + let imported = import_editor_preset(&directory.path().join("imported"), preset); + let position = &imported.key_positions["4key"][0]; + assert_eq!(imported.keys["4key"], vec!["KeyQ"]); + assert_eq!(position.background_color.as_deref(), Some("#123456")); + assert_eq!(position.active_background_color.as_deref(), Some("#234567")); + assert_eq!(position.border_color.as_deref(), Some("#345678")); + assert_eq!(position.active_border_color.as_deref(), Some("#456789")); + assert_eq!(position.counter.fill.idle, "#ABCDEF"); + assert_eq!(position.counter.fill.active, "#FEDCBA"); + assert!(position.background_gradient.is_none()); + assert!(position.active_background_gradient.is_none()); + assert!(position.border_gradient.is_none()); + assert!(position.active_border_gradient.is_none()); + assert!(position.counter.fill_idle_gradient.is_none()); + assert!(position.counter.fill_active_gradient.is_none()); + let imported_value = serde_json::to_value(&imported).unwrap(); + assert!(!contains_gradient_fields(&imported_value)); + + println!( + "SIMULATION 5 PASS: legacyPresetParsed=true imported=true baseAndCounterColorsPreserved=true gradientsAbsent=true" + ); +} diff --git a/src/renderer/__tests__/activeStatePickerCapability.test.tsx b/src/renderer/__tests__/activeStatePickerCapability.test.tsx new file mode 100644 index 00000000..b1f1273e --- /dev/null +++ b/src/renderer/__tests__/activeStatePickerCapability.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ColorInput } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; +import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; + +interface CapturedColorPickerProps { + color: string; + stateMode?: 'idle' | 'active'; + onStateModeChange?: (mode: 'idle' | 'active') => void; + onColorChangeComplete: (color: string) => void; +} + +const captured = vi.hoisted(() => ({ + colorPickerProps: null as CapturedColorPickerProps | null, +})); + +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: (props: CapturedColorPickerProps) => { + captured.colorPickerProps = props; + return
; + }, +})); + +vi.mock('@components/main/Modal/FloatingPopup', () => ({ + default: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, +})); + +vi.mock('@hooks/ui/usePanelAnchoredPopupPosition', () => ({ + usePanelAnchoredPopupPosition: () => null, +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('상태별 피커 capability 전환', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + captured.colorPickerProps = null; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('ColorInput은 상태 탭이 사라지면 열린 피커를 닫고 대기 색만 기록한다', () => { + const onChange = vi.fn(); + const onActiveChange = vi.fn(); + const renderInput = (showStateTabs: boolean) => + root.render( + , + ); + + act(() => renderInput(true)); + act(() => host.querySelector('button')?.click()); + act(() => captured.colorPickerProps?.onStateModeChange?.('active')); + expect(captured.colorPickerProps?.stateMode).toBe('active'); + + act(() => renderInput(false)); + expect(host.querySelector('[data-testid="color-picker"]')).toBeNull(); + + act(() => host.querySelector('button')?.click()); + expect(captured.colorPickerProps?.stateMode).toBeUndefined(); + expect(captured.colorPickerProps?.color).toBe('#111111'); + act(() => captured.colorPickerProps?.onColorChangeComplete('#abcdef')); + + expect(onChange).toHaveBeenLastCalledWith('#abcdef'); + expect(onActiveChange).not.toHaveBeenCalled(); + }); + + it('ImagePicker는 capability가 사라지면 탭 없이 대기 이미지 경로만 쓴다', () => { + const onIdleImageReset = vi.fn(); + const onActiveImageReset = vi.fn(); + const referenceRef = React.createRef(); + const renderPicker = (showActiveState: boolean) => + root.render( + , + ); + + act(() => renderPicker(true)); + const activeTab = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'imagePicker.active', + ); + act(() => activeTab?.click()); + + act(() => renderPicker(false)); + expect(host.textContent).not.toContain('imagePicker.active'); + act(() => + host + .querySelector('[title="imagePicker.reset"]') + ?.click(), + ); + + expect(onIdleImageReset).toHaveBeenCalledOnce(); + expect(onActiveImageReset).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/__tests__/batchShadowUpdate.test.ts b/src/renderer/__tests__/batchShadowUpdate.test.ts new file mode 100644 index 00000000..cd2185b9 --- /dev/null +++ b/src/renderer/__tests__/batchShadowUpdate.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it, vi } from 'vitest'; +import { useBatchHandlers } from '@components/main/Grid/PropertiesPanel/batch/useBatchHandlers'; +import type { KeyPosition } from '@src/types/key/keys'; +import { normalizeCounterSettings } from '@src/types/key/keys'; +import type { GradientSpec } from '@src/types/color'; + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { commitPatch: vi.fn().mockResolvedValue(undefined) }, +})); + +const position = ( + color: string, + overrides: Partial = {}, +): KeyPosition => + ({ + dx: 0, + dy: 0, + width: 60, + height: 60, + count: 0, + shadow: { + enabled: true, + color, + offsetX: 0, + offsetY: 4, + blur: 10, + }, + ...overrides, + } as KeyPosition); + +describe('배치 그림자 부분 변경', () => { + it('바꾼 항목만 합치고 요소별 기존 색상은 보존한다', () => { + const onKeyBatchUpdate = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', index: 0 }, + { type: 'key', index: 1 }, + ], + keyPositions: { + '4key': [position('#111111'), position('#eeeeee')], + }, + statPositions: {}, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + }); + + handlers.handleBatchShadowChangeComplete('idle', { blur: 24 }); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [ + { + index: 0, + shadow: { + enabled: true, + color: '#111111', + offsetX: 0, + offsetY: 4, + blur: 24, + }, + }, + { + index: 1, + shadow: { + enabled: true, + color: '#eeeeee', + offsetX: 0, + offsetY: 4, + blur: 24, + }, + }, + ], + { skipHistory: false, deferSave: true }, + ); + }); + + it('마스터 토글이 요소별 값을 보존하며 대기·입력을 함께 끈다', () => { + const onKeyBatchUpdate = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [{ type: 'key', index: 0 }], + keyPositions: { + '4key': [position('#111111')], + }, + statPositions: {}, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + }); + + handlers.handleBatchShadowEnabledChange(false); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [ + { + index: 0, + shadow: { + enabled: false, + color: '#111111', + offsetX: 0, + offsetY: 4, + blur: 10, + }, + // 저장된 activeShadow가 없으면 기본 입력 그림자 기준으로 끔 + activeShadow: { + enabled: false, + color: 'rgba(0, 0, 0, 0.32)', + offsetX: 0, + offsetY: 3, + blur: 8, + }, + }, + ], + { skipHistory: false, deferSave: true }, + ); + }); +}); + +describe('통계 그림자 — 눌림 상태 없음', () => { + const statHandlerArgs = () => { + const onStatBatchUpdate = vi.fn(); + return { + onStatBatchUpdate, + args: { + selectedKeyLikeElements: [{ type: 'stat' as const, index: 0 }], + keyPositions: {}, + statPositions: { + '4key': [{ ...position('#111111'), statType: 'kps' as const }], + }, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate: vi.fn(), + onStatUpdate: vi.fn(), + onStatBatchUpdate, + }, + }; + }; + + it('입력 상태 패치는 통계에 기록되지 않는다', () => { + const { args, onStatBatchUpdate } = statHandlerArgs(); + const handlers = useBatchHandlers(args); + handlers.handleBatchShadowChangeComplete('active', { blur: 24 }); + expect(onStatBatchUpdate).toHaveBeenCalledWith([{ index: 0 }], { + skipHistory: false, + deferSave: true, + }); + }); + + it('마스터 토글은 통계에 shadow만 기록한다', () => { + const { args, onStatBatchUpdate } = statHandlerArgs(); + const handlers = useBatchHandlers(args); + handlers.handleBatchShadowEnabledChange(false); + const [updates] = onStatBatchUpdate.mock.calls[0]; + expect(updates[0].shadow.enabled).toBe(false); + expect(updates[0]).not.toHaveProperty('activeShadow'); + }); +}); + +describe('눌림 가능(키·노브) active 쓰기', () => { + it('activeCapable 쓰기가 키·노브에 전달되고 통계는 제외된다', () => { + const onKeyBatchUpdate = vi.fn(); + const onKnobBatchUpdate = vi.fn(); + const onStatBatchUpdate = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', index: 0 }, + { type: 'knob', index: 0 }, + { type: 'stat', index: 0 }, + ], + keyPositions: { '4key': [position('#111111')] }, + statPositions: { + '4key': [{ ...position('#111111'), statType: 'kps' as const }], + }, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + onStatBatchUpdate, + onKnobBatchUpdate, + }); + + handlers.handleActiveCapableStyleChangeComplete('activeImage', 'img.svg'); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [{ index: 0, activeImage: 'img.svg' }], + expect.anything(), + ); + expect(onKnobBatchUpdate).toHaveBeenCalledWith( + [{ index: 0, activeImage: 'img.svg' }], + expect.anything(), + ); + // 통계에는 active 필드가 기록되지 않음 + for (const call of onStatBatchUpdate.mock.calls) { + for (const update of call[0]) { + expect(update).not.toHaveProperty('activeImage'); + } + } + }); +}); + +describe('입력 그라데이션 커밋 대상', () => { + it('active 커밋은 키·노브만 — 통계·그래프 제외', () => { + const onKeyBatchUpdate = vi.fn(); + const onGraphBatchUpdate = vi.fn(); + const onStatBatchUpdate = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', index: 0 }, + { type: 'stat', index: 0 }, + { type: 'graph', index: 0 }, + ], + keyPositions: { '4key': [position('#111111')] }, + statPositions: { + '4key': [{ ...position('#111111'), statType: 'kps' as const }], + }, + graphPositions: { + '4key': [ + { + ...position('#111111'), + statType: 'kps' as const, + graphType: 'line' as const, + graphSpeed: 1, + graphColor: '#ffffff', + }, + ], + }, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + onStatBatchUpdate, + onGraphBatchUpdate, + }); + + handlers.handleBatchGradientCommit('backgroundColor', 'active', { + mode: 'solid', + color: '#abcdef', + }); + + expect(onKeyBatchUpdate).toHaveBeenCalled(); + expect(onGraphBatchUpdate).not.toHaveBeenCalled(); + expect(onStatBatchUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('배치 색 상태 쌍 보존', () => { + const useTestHandlers = (keyPosition: KeyPosition) => { + const onKeyBatchUpdate = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [{ type: 'key', index: 0 }], + keyPositions: { '4key': [keyPosition] }, + statPositions: {}, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + }); + return { handlers, onKeyBatchUpdate }; + }; + + it('저장된 idle 단색이 없으면 active 기본값을 실체화하지 않는다', () => { + const { handlers, onKeyBatchUpdate } = useTestHandlers(position('#111111')); + + handlers.handleBatchStyleChangeComplete('backgroundColor', '#abcdef'); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [{ index: 0, backgroundColor: '#abcdef' }], + { skipHistory: false, deferSave: true }, + ); + }); + + it('저장된 idle 단색은 active가 비어 있을 때 동결한다', () => { + const { handlers, onKeyBatchUpdate } = useTestHandlers( + position('#111111', { backgroundColor: '#123456' }), + ); + + handlers.handleBatchStyleChangeComplete('backgroundColor', '#abcdef'); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [ + { + index: 0, + backgroundColor: '#abcdef', + activeBackgroundColor: '#123456', + }, + ], + { skipHistory: false, deferSave: true }, + ); + }); + + it('저장된 idle 그라데이션이 없으면 active 쌍을 실체화하지 않는다', () => { + const { handlers, onKeyBatchUpdate } = useTestHandlers(position('#111111')); + + handlers.handleBatchGradientCommit('backgroundColor', 'idle', { + mode: 'solid', + color: '#abcdef', + }); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [ + { + index: 0, + backgroundColor: '#abcdef', + backgroundGradient: undefined, + }, + ], + { skipHistory: false, deferSave: true }, + ); + }); + + it('저장된 idle 그라데이션 쌍은 active가 비어 있을 때 동결한다', () => { + const gradient: GradientSpec = { + angle: 45, + stops: [ + { color: '#123456', pos: 0 }, + { color: '#654321', pos: 1 }, + ], + }; + const { handlers, onKeyBatchUpdate } = useTestHandlers( + position('#111111', { + backgroundColor: '#123456', + backgroundGradient: gradient, + }), + ); + + handlers.handleBatchGradientCommit('backgroundColor', 'idle', { + mode: 'solid', + color: '#abcdef', + }); + + expect(onKeyBatchUpdate).toHaveBeenCalledWith( + [ + { + index: 0, + backgroundColor: '#abcdef', + backgroundGradient: undefined, + activeBackgroundColor: '#123456', + activeBackgroundGradient: gradient, + }, + ], + { skipHistory: false, deferSave: true }, + ); + }); +}); + +describe('통계 active 스타일 쓰기 차단', () => { + const useMixedHandlerArgs = () => { + const onKeyBatchUpdate = vi.fn(); + const onStatBatchUpdate = vi.fn(); + const keyCounter = normalizeCounterSettings(undefined); + keyCounter.fill.active = '#121212'; + const statCounter = normalizeCounterSettings(undefined); + statCounter.fill.active = '#343434'; + return { + keyCounter, + statCounter, + onKeyBatchUpdate, + onStatBatchUpdate, + handlers: useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', index: 0 }, + { type: 'stat', index: 0 }, + ], + keyPositions: { + '4key': [position('#111111', { counter: keyCounter })], + }, + statPositions: { + '4key': [ + { + ...position('#222222', { + backgroundColor: '#333333', + counter: statCounter, + }), + statType: 'kps' as const, + }, + ], + }, + selectedKeyType: '4key', + onKeyUpdate: vi.fn(), + onKeyBatchUpdate, + onStatUpdate: vi.fn(), + onStatBatchUpdate, + }), + }; + }; + + it('active 색과 그라데이션은 통계 업데이트에서 제외한다', () => { + const { handlers, onKeyBatchUpdate, onStatBatchUpdate } = + useMixedHandlerArgs(); + + handlers.handleBatchStyleChangeComplete('activeBackgroundColor', '#abcdef'); + handlers.handleBatchGradientCommit('backgroundColor', 'active', { + mode: 'solid', + color: '#fedcba', + }); + + expect(onKeyBatchUpdate).toHaveBeenCalledTimes(2); + expect(onStatBatchUpdate).not.toHaveBeenCalled(); + }); + + it('통계 idle 색 변경은 active 쌍을 새로 기록하지 않는다', () => { + const { handlers, onStatBatchUpdate } = useMixedHandlerArgs(); + + handlers.handleBatchStyleChangeComplete('backgroundColor', '#abcdef'); + + const [updates] = onStatBatchUpdate.mock.calls[0]; + expect(updates).toEqual([{ index: 0, backgroundColor: '#abcdef' }]); + expect(updates[0]).not.toHaveProperty('activeBackgroundColor'); + }); + + it('active 카운터 색은 혼합 선택의 키에만 기록한다', () => { + const { handlers, keyCounter, onKeyBatchUpdate, onStatBatchUpdate } = + useMixedHandlerArgs(); + + handlers.handleBatchCounterUpdate( + { + fill: { ...keyCounter.fill, active: '#abcdef' }, + }, + { activeStateOnly: true, colorState: 'active' }, + ); + + const [updates] = onKeyBatchUpdate.mock.calls[0]; + expect(updates[0].counter.fill.active).toBe('#abcdef'); + expect(onStatBatchUpdate).not.toHaveBeenCalled(); + }); + + it('idle 카운터 색은 요소별 기존 active 쌍을 보존한다', () => { + const { + handlers, + keyCounter, + statCounter, + onKeyBatchUpdate, + onStatBatchUpdate, + } = useMixedHandlerArgs(); + + handlers.handleBatchCounterUpdate( + { + fill: { idle: '#abcdef', active: '#ffffff' }, + }, + { colorState: 'idle' }, + ); + + const [keyUpdates] = onKeyBatchUpdate.mock.calls[0]; + const [statUpdates] = onStatBatchUpdate.mock.calls[0]; + expect(keyUpdates[0].counter.fill).toEqual({ + idle: '#abcdef', + active: keyCounter.fill.active, + }); + expect(statUpdates[0].counter.fill).toEqual({ + idle: '#abcdef', + active: statCounter.fill.active, + }); + }); +}); diff --git a/src/renderer/__tests__/batchStyleActiveAggregation.test.tsx b/src/renderer/__tests__/batchStyleActiveAggregation.test.tsx new file mode 100644 index 00000000..72af9605 --- /dev/null +++ b/src/renderer/__tests__/batchStyleActiveAggregation.test.tsx @@ -0,0 +1,294 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { KeyPosition } from '@src/types/key/keys'; +import type { ElementShadowSpec } from '@src/types/key/shadows'; +import BatchStyleTabContent from '@components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent'; +import { BatchGraphOnlyPanel } from '@components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel'; +import { PanelNavProvider } from '@components/main/Grid/PropertiesPanel/PanelNavContext'; + +interface CapturedShadowProps { + activeShadow: ElementShadowSpec; + activeMixed?: boolean; + anyEnabled?: boolean; +} + +const captured = vi.hoisted(() => ({ + shadowProps: null as CapturedShadowProps | null, + colorTabs: [] as Array, +})); + +vi.mock( + '@components/main/Grid/PropertiesPanel/index', + async (importOriginal) => { + const mod = await importOriginal< + typeof import('@components/main/Grid/PropertiesPanel/index') + >(); + return { + ...mod, + ColorInput: (props: { showStateTabs?: boolean }) => { + captured.colorTabs.push(props.showStateTabs); + return
; + }, + }; + }, +); + +vi.mock('@components/main/Grid/PropertiesPanel/ShadowControls', () => ({ + default: (props: CapturedShadowProps) => { + captured.shadowProps = props; + return
; + }, +})); + +const position = ( + shadow: ElementShadowSpec, + activeShadow: ElementShadowSpec, +): KeyPosition => + ({ + dx: 0, + dy: 0, + width: 60, + height: 60, + count: 0, + shadow, + activeShadow, + } as KeyPosition); + +const mixedGetter = (positions: KeyPosition[]) => + function getMixedValue( + getter: (pos: KeyPosition) => T | undefined, + defaultValue: T, + ): { isMixed: boolean; value: T } { + const values = positions.map((item) => getter(item) ?? defaultValue); + const value = values[0] ?? defaultValue; + return { + value, + isMixed: values.some( + (candidate) => JSON.stringify(candidate) !== JSON.stringify(value), + ), + }; + }; + +describe('혼합 선택 active 그림자 집계', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + captured.shadowProps = null; + captured.colorTabs = []; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('키와 통계를 함께 골라도 active 대표값·Mixed·anyEnabled는 키만 본다', () => { + const idleShadow: ElementShadowSpec = { + enabled: false, + color: '#111111', + offsetX: 0, + offsetY: 4, + blur: 10, + }; + const keyActiveShadow: ElementShadowSpec = { + enabled: false, + color: '#22aa22', + offsetX: 1, + offsetY: 2, + blur: 7, + }; + const staleStatActiveShadow: ElementShadowSpec = { + enabled: true, + color: '#ff0000', + offsetX: 90, + offsetY: 91, + blur: 92, + }; + const keyPosition = position(idleShadow, keyActiveShadow); + const statPosition = position(idleShadow, staleStatActiveShadow); + + act(() => { + root.render( + + []} + handleBatchAlign={vi.fn()} + handleBatchDistribute={vi.fn()} + handleBatchSpacing={vi.fn()} + batchSpacing={{ isMixed: false, value: 0 }} + handleBatchResize={vi.fn()} + handleBatchStyleChange={vi.fn()} + handleBatchStyleChangeComplete={vi.fn()} + handleBatchShadowChangeComplete={vi.fn()} + handleBatchShadowEnabledChange={vi.fn()} + showBatchImagePicker={false} + onToggleBatchImagePicker={vi.fn()} + batchImageButtonRef={React.createRef()} + panelElement={null} + useCustomCSS={false} + t={(key) => key} + /> + , + ); + }); + + expect(captured.shadowProps?.activeShadow).toEqual(keyActiveShadow); + expect(captured.shadowProps?.activeMixed).toBe(false); + expect(captured.shadowProps?.anyEnabled).toBe(false); + }); + + it('shadowActiveState=false(그래프 배치)면 색상 입력 탭이 전부 꺼진다', () => { + const shadow: ElementShadowSpec = { + enabled: false, + color: '#111111', + offsetX: 0, + offsetY: 4, + blur: 10, + }; + const graphPosition = position(shadow, shadow); + + act(() => { + root.render( + + []} + handleBatchAlign={vi.fn()} + handleBatchDistribute={vi.fn()} + handleBatchSpacing={vi.fn()} + batchSpacing={{ isMixed: false, value: 0 }} + handleBatchResize={vi.fn()} + handleBatchStyleChange={vi.fn()} + handleBatchStyleChangeComplete={vi.fn()} + handleBatchShadowChangeComplete={vi.fn()} + handleBatchShadowEnabledChange={vi.fn()} + showBatchImagePicker={false} + onToggleBatchImagePicker={vi.fn()} + batchImageButtonRef={React.createRef()} + panelElement={null} + useCustomCSS={false} + t={(key) => key} + /> + , + ); + }); + + // 배경·테두리 ColorInput 모두 상태 탭 미노출 + expect(captured.colorTabs.length).toBeGreaterThan(0); + expect(captured.colorTabs.every((tabs) => tabs === false)).toBe(true); + }); +}); + +describe('그래프 전용 배치 패널 배선', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + captured.colorTabs = []; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('그래프 패널 색상 피커는 실제 배선으로 입력 탭이 꺼진다', () => { + const mixedGraphs = ( + _getter: unknown, + defaultValue: T, + ): { isMixed: boolean; value: T } => ({ + isMixed: false, + value: defaultValue, + }); + + act(() => { + root.render( + + ()} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + handleBatchAlign={vi.fn()} + handleBatchDistribute={vi.fn()} + handleBatchSpacing={vi.fn()} + handleBatchSpacingPreview={vi.fn()} + handleBatchSpacingCommit={vi.fn()} + getBatchSpacingValue={() => ({ isMixed: false, value: 0 })} + handleBatchResize={vi.fn()} + handleBatchStyleChange={vi.fn()} + handleBatchStyleChangeComplete={vi.fn()} + handleGraphBatchSharedSetting={vi.fn()} + getMixedValueGraphs={mixedGraphs} + getMixedValueGraphsAsKey={mixedGraphs} + getSelectedGraphsData={() => []} + batchScrollRefFor={() => () => {}} + batchImageButtonRef={React.createRef()} + showBatchImagePicker={false} + setShowBatchImagePicker={vi.fn()} + panelElement={null} + useCustomCSS={false} + selectedKeyType="4key" + t={(key) => key} + /> + , + ); + }); + + // 실제 배선 고정 — BatchGraphOnlyPanel의 shadowActiveState={false}를 + // 지우면 기본값 true로 탭이 켜져 이 테스트가 실패해야 함 + expect(captured.colorTabs.length).toBeGreaterThan(0); + expect(captured.colorTabs.every((tabs) => tabs === false)).toBe(true); + }); +}); diff --git a/src/renderer/__tests__/colorPaletteStorage.test.ts b/src/renderer/__tests__/colorPaletteStorage.test.ts new file mode 100644 index 00000000..4f2fe81d --- /dev/null +++ b/src/renderer/__tests__/colorPaletteStorage.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +/** + * 팔레트 저장 유틸 테스트 — localStorage는 외부 입력이므로 + * 로드 경계 파서가 손상 항목을 걸러내고 spec을 canonical로 교정하는지 검증 + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + loadPalette, + addToPalette, + gradientSpecPaletteEntry, + isGradientSpecColor, +} from '@utils/color/colorPaletteStorage'; + +const GRADIENT_KEY = 'dmnote-color-palette-gradient'; +const SOLID_KEY = 'dmnote-color-palette-solid'; + +describe('loadPalette 경계 파서', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('null 스톱이 섞인 spec 항목은 제거된다', () => { + localStorage.setItem( + GRADIENT_KEY, + JSON.stringify([ + { type: 'gradient-spec', angle: 90, stops: [null, null] }, + { type: 'gradient', top: '#ff0000', bottom: '#0000ff' }, + ]), + ); + const loaded = loadPalette('gradient'); + expect(loaded).toHaveLength(1); + expect(loaded[0]).toEqual({ + type: 'gradient', + top: '#ff0000', + bottom: '#0000ff', + }); + }); + + it('범위 밖 각도·pos는 canonical로 교정해 반환한다', () => { + localStorage.setItem( + GRADIENT_KEY, + JSON.stringify([ + { + type: 'gradient-spec', + angle: 450, + stops: [ + { color: '#00ff00', pos: 1.5 }, + { color: '#ff0000', pos: -0.5 }, + ], + }, + ]), + ); + const [entry] = loadPalette('gradient'); + expect(isGradientSpecColor(entry)).toBe(true); + if (isGradientSpecColor(entry)) { + expect(entry.angle).toBe(90); + // pos clamp 후 오름차순 정렬 + expect(entry.stops.map((s) => s.pos)).toEqual([0, 1]); + expect(entry.stops[0].color).toBe('#ff0000'); + } + }); + + it('솔리드 버킷은 문자열만 통과시키고 7개로 자른다', () => { + localStorage.setItem( + SOLID_KEY, + JSON.stringify([ + '#111111', + { type: 'gradient-spec', angle: 90, stops: [] }, + '#222222', + null, + '#333333', + '#444444', + '#555555', + '#666666', + '#777777', + '#888888', + ]), + ); + const loaded = loadPalette('solid'); + expect(loaded).toHaveLength(7); + expect(loaded.every((c) => typeof c === 'string')).toBe(true); + }); + + it('저장 후 재로드 왕복이 무손실이다', () => { + addToPalette( + 'gradient', + gradientSpecPaletteEntry({ + angle: 135, + stops: [ + { color: 'rgba(255,0,0,0.72)', pos: 0 }, + { color: 'rgba(255,0,0,0)', pos: 1 }, + ], + }), + ); + const [entry] = loadPalette('gradient'); + expect(isGradientSpecColor(entry)).toBe(true); + if (isGradientSpecColor(entry)) { + expect(entry.angle).toBe(135); + expect(entry.stops).toHaveLength(2); + } + }); +}); diff --git a/src/renderer/__tests__/counterAnimationPreviewContract.test.ts b/src/renderer/__tests__/counterAnimationPreviewContract.test.ts new file mode 100644 index 00000000..2984de5f --- /dev/null +++ b/src/renderer/__tests__/counterAnimationPreviewContract.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { computeCounterAnimationPreviewKeyStyles } from '@utils/core/counterAnimationPreview'; +import type { GradientSpec } from '@src/types/color'; + +const ring: GradientSpec = { + angle: 90, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: '#0000ff', pos: 1 }, + ], +}; + +describe('카운터 애니메이션 키 프리뷰 계약', () => { + it('이미지 키는 실제 렌더처럼 이미지와 fit을 쓰고 기본 표면을 억제한다', () => { + const idle = computeCounterAnimationPreviewKeyStyles({ + keyVisual: { + inactiveImage: 'data:image/png;base64,AA==', + idleImageFit: 'contain', + }, + active: false, + width: 60, + height: 60, + }); + const active = computeCounterAnimationPreviewKeyStyles({ + keyVisual: { + inactiveImage: 'data:image/png;base64,AA==', + idleImageFit: 'contain', + }, + active: true, + width: 60, + height: 60, + }); + + expect(idle.hasCurrentImage).toBe(true); + expect(idle.currentImageSrc).toBe('data:image/png;base64,AA=='); + expect(idle.imageStyle.objectFit).toBe('contain'); + expect(idle.keyStyle['--dmn-key-bg-default']).toBe('transparent'); + expect(idle.keyStyle['--dmn-key-border-default']).toBe('none'); + expect(idle.keyStyle['--dmn-key-shadow-default']).toBe('none'); + expect(active.imageStyle.filter).toBe('brightness(0.62)'); + }); + + it('한 상태에만 링이 있어도 반대 상태의 패딩을 예약한다', () => { + const idle = computeCounterAnimationPreviewKeyStyles({ + keyVisual: { + activeBorderGradient: ring, + inactiveImage: 'data:image/png;base64,AA==', + }, + active: false, + width: 60, + height: 60, + }); + + expect(idle.borderRingStyle).toBeNull(); + expect(idle.keyStyle['--dmn-key-padding-default']).toBe('1px'); + }); + + it('현재 상태의 투명도 설정을 그대로 반환한다', () => { + const active = computeCounterAnimationPreviewKeyStyles({ + keyVisual: { activeTransparent: true }, + active: true, + width: 60, + height: 60, + }); + + expect(active.isTransparent).toBe(true); + }); +}); diff --git a/src/renderer/__tests__/editorDocumentSelection.test.ts b/src/renderer/__tests__/editorDocumentSelection.test.ts new file mode 100644 index 00000000..af7eb319 --- /dev/null +++ b/src/renderer/__tests__/editorDocumentSelection.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { applyEditorDocument } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import type { EditorDocumentV1 } from '@src/types/editor'; +import type { StatItemPosition } from '@src/types/key/statItems'; + +const makeStat = (x = 0): StatItemPosition => ({ + ...createDefaultKeyPosition(), + dx: x, + statType: 'kps', +}); + +const makeDocument = (): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: { '4key': ['B'] }, + keyPositions: { '4key': [createDefaultKeyPosition()] }, + statPositions: { '4key': [] }, + graphPositions: { '4key': [] }, + knobPositions: { '4key': [] }, + layerGroups: { '4key': [] }, +}); + +describe('editor document 적용 선택 정합성', () => { + beforeEach(() => { + const document = makeDocument(); + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: document.keys, + positions: document.keyPositions, + isBootstrapped: false, + }); + useStatItemStore.setState({ positions: document.statPositions }); + useGraphItemStore.setState({ positions: document.graphPositions }); + useKnobItemStore.setState({ positions: document.knobPositions }); + useLayerGroupStore.setState({ layerGroups: document.layerGroups }); + useGridSelectionStore.getState().clearSelection(); + }); + + afterEach(() => useGridSelectionStore.getState().clearSelection()); + + it('선택된 종류의 배열 길이가 바뀌면 해당 선택을 무효화한다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + const next = makeDocument(); + next.keys['4key'] = ['A', 'B']; + next.keyPositions['4key'] = [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ]; + + applyEditorDocument(next); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + }); + + it('길이가 같은 스타일 갱신은 현재 선택을 보존한다', () => { + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + const selectionReference = + useGridSelectionStore.getState().selectedElements; + const next = makeDocument(); + next.keyPositions['4key'][0] = { + ...next.keyPositions['4key'][0], + backgroundColor: '#123456', + }; + + applyEditorDocument(next); + + expect(useGridSelectionStore.getState().selectedElements).toBe( + selectionReference, + ); + }); + + it('배열 끝 추가는 앞쪽의 유효한 선택을 보존한다', () => { + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + const selectionReference = + useGridSelectionStore.getState().selectedElements; + const next = makeDocument(); + next.keys['4key'] = ['B', 'C']; + next.keyPositions['4key'] = [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ]; + + applyEditorDocument(next); + + expect(useGridSelectionStore.getState().selectedElements).toBe( + selectionReference, + ); + }); + + it('다른 종류의 배열 길이 변경은 관련 없는 선택을 보존한다', () => { + useStatItemStore.setState({ positions: { '4key': [makeStat(10)] } }); + useGridSelectionStore.getState().setSelectedElements([ + { type: 'key', id: 'key-0', index: 0 }, + { type: 'stat', id: 'stat-0', index: 0 }, + { type: 'plugin', id: 'plugin:test' }, + ]); + const next = makeDocument(); + next.statPositions['4key'] = [makeStat(), makeStat(10)]; + + applyEditorDocument(next); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'key', id: 'key-0', index: 0 }, + { type: 'plugin', id: 'plugin:test' }, + ]); + }); +}); diff --git a/src/renderer/__tests__/elementShadowContract.test.ts b/src/renderer/__tests__/elementShadowContract.test.ts new file mode 100644 index 00000000..9618fc42 --- /dev/null +++ b/src/renderer/__tests__/elementShadowContract.test.ts @@ -0,0 +1,202 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { computeKeyElementStyles } from '@hooks/overlay/useKeyElementStyles'; +import { + DEFAULT_ELEMENT_ACTIVE_SHADOW, + DEFAULT_ELEMENT_SHADOW, +} from '@utils/core/elementDefaults'; +import { + elementShadowSpecSchema, + elementShadowToCss, + type ElementShadowSpec, +} from '@src/types/key/shadows'; + +const normalizeCssValue = (value: string) => value.replace(/\s+/g, ' ').trim(); + +describe('기본 요소 눌림 섀도 계약', () => { + it('입력 그라데이션 미리보기에서도 입력 보더 쌍을 함께 사용한다', () => { + const { keyStyle, borderRingStyle } = computeKeyElementStyles({ + active: true, + label: '.', + position: { + dx: 0, + dy: 0, + width: 60, + height: 60, + useInlineStyles: true, + backgroundColor: 'rgba(0, 0, 255, 0.72)', + activeBackgroundColor: 'rgba(255, 0, 0, 0.88)', + activeBackgroundGradient: { + angle: 225, + stops: [ + { color: 'rgba(255, 0, 0, 0.88)', pos: 0 }, + { color: 'rgba(255, 0, 0, 0)', pos: 0.38 }, + ], + }, + borderColor: 'rgba(255, 0, 0, 1)', + activeBorderColor: 'rgba(255, 255, 255, 0)', + }, + }); + + expect(keyStyle.backgroundImage).toContain('rgba(255, 0, 0, 0.88)'); + expect(keyStyle.backgroundClip).toBe('padding-box'); + expect(keyStyle.border).toBe('1px solid rgba(255, 255, 255, 0)'); + expect(borderRingStyle).toBeNull(); + }); + + it('투명 보더를 테두리처럼 보이게 하는 inset을 사용하지 않는다', () => { + expect(DEFAULT_ELEMENT_ACTIVE_SHADOW).not.toContain('inset'); + }); + + it('그림자 입력 범위를 검증한다', () => { + const valid: ElementShadowSpec = { + enabled: true, + color: 'rgba(255, 0, 0, 0.4)', + offsetX: -100, + offsetY: 100, + blur: 100, + }; + + expect(elementShadowSpecSchema.parse(valid)).toEqual(valid); + expect( + elementShadowSpecSchema.safeParse({ ...valid, offsetX: -100.1 }).success, + ).toBe(false); + expect( + elementShadowSpecSchema.safeParse({ ...valid, blur: 100.1 }).success, + ).toBe(false); + }); + + it('일반 모드는 fallback 변수만 제공하고 인라인 우선 모드만 직접 적용한다', () => { + const shadow: ElementShadowSpec = { + enabled: true, + color: 'rgba(12, 34, 56, 0.45)', + offsetX: -2, + offsetY: 7, + blur: 18, + }; + const position = { + dx: 0, + dy: 0, + width: 60, + height: 60, + shadow, + }; + const fallback = computeKeyElementStyles({ + active: false, + label: 'A', + position, + }).keyStyle as Record; + const inline = computeKeyElementStyles({ + active: false, + label: 'A', + position: { ...position, useInlineStyles: true }, + }).keyStyle; + + expect(fallback.boxShadow).toBeUndefined(); + expect(fallback['--dmn-key-shadow-default']).toBe( + elementShadowToCss(shadow), + ); + expect(inline.boxShadow).toBe(elementShadowToCss(shadow)); + }); + + it('기존 데이터의 기본값과 명시적 끄기를 상태별로 구분한다', () => { + const basePosition = { dx: 0, dy: 0, width: 60, height: 60 }; + const idle = computeKeyElementStyles({ + active: false, + label: 'A', + position: basePosition, + }).keyStyle as Record; + const active = computeKeyElementStyles({ + active: true, + label: 'A', + position: basePosition, + }).keyStyle as Record; + const disabled = computeKeyElementStyles({ + active: true, + label: 'A', + position: { + ...basePosition, + activeShadow: { + enabled: false, + color: 'rgba(0, 0, 0, 0.32)', + offsetX: 0, + offsetY: 3, + blur: 8, + }, + }, + }).keyStyle as Record; + + expect(idle['--dmn-key-shadow-default']).toBe(DEFAULT_ELEMENT_SHADOW); + expect(active['--dmn-key-shadow-default']).toBe( + DEFAULT_ELEMENT_ACTIVE_SHADOW, + ); + expect(disabled['--dmn-key-shadow-default']).toBe('none'); + }); + + it('이미지는 기본 그림자만 억제하고 사용자가 지정한 그림자는 보존한다', () => { + const basePosition = { + dx: 0, + dy: 0, + width: 60, + height: 60, + inactiveImage: 'asset://key.png', + }; + const defaultImage = computeKeyElementStyles({ + active: false, + label: 'A', + position: basePosition, + }).keyStyle as Record; + const explicitImage = computeKeyElementStyles({ + active: false, + label: 'A', + position: { + ...basePosition, + shadow: { + enabled: true, + color: '#0008', + offsetX: 0, + offsetY: 6, + blur: 12, + }, + }, + }).keyStyle as Record; + + expect(defaultImage['--dmn-key-shadow-default']).toBe('none'); + expect(explicitImage['--dmn-key-shadow-default']).toBe( + '0px 6px 12px #0008', + ); + }); + + it('전역 키·노브 규칙이 공개 변수 뒤에 앱 fallback을 둔다', () => { + const css = readFileSync( + resolve(process.cwd(), 'src/renderer/styles/global.css'), + 'utf8', + ); + expect(normalizeCssValue(css)).toContain( + 'box-shadow: var( --key-active-shadow, var(--key-shadow, var(--dmn-key-shadow-default, none)) )', + ); + expect(css).toContain('--knob-active-shadow'); + expect(css).toMatch( + /:where\(\[data-key-element\], \[data-graph-element\], \[data-knob-element\]\)\s*\{[^}]*background-clip:\s*padding-box;/, + ); + }); + + it('background 축약 속성을 쓰는 표면도 padding-box 클립을 명시한다', () => { + const files = [ + 'src/renderer/components/main/Grid/layers/KnobItem.tsx', + 'src/renderer/components/overlay/counters/OverlayKnobItem.tsx', + 'src/renderer/components/shared/GraphPanel.tsx', + ]; + + for (const file of files) { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + expect(source).toContain("backgroundClip: 'padding-box'"); + } + + for (const file of files.slice(0, 2)) { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + expect(source).not.toContain("contain: 'layout style paint'"); + } + }); +}); diff --git a/src/renderer/__tests__/gradientAxisHandle.test.tsx b/src/renderer/__tests__/gradientAxisHandle.test.tsx new file mode 100644 index 00000000..0823a09c --- /dev/null +++ b/src/renderer/__tests__/gradientAxisHandle.test.tsx @@ -0,0 +1,535 @@ +// @vitest-environment jsdom +/** + * 온캔버스 그라데이션 축 핸들 로직 테스트 + * 축·색 분리 모델: 축 선/회전 핸들 드래그 → 각도만, 스톱 점 드래그 → pos만, + * 축 선 클릭 → 스톱 추가, 키보드 화살표 → 각도 커밋 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import GradientAxisOverlay from '@components/main/Grid/handles/GradientAxisHandle'; +import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; +import type { GradientSpec } from '@src/types/color'; + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const SPEC: GradientSpec = { + angle: 90, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: '#0000ff', pos: 1 }, + ], +}; + +const positions = { + '4key': [{ dx: 100, dy: 100, width: 200, height: 100 }], +} as never; + +// jsdom에는 PointerEvent가 없어 MouseEvent 기반으로 합성 +const pointerEvent = ( + type: string, + init: { + pointerId: number; + clientX: number; + clientY: number; + button?: number; + }, +) => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX, + clientY: init.clientY, + button: init.button ?? 0, + // 드래그 중 버튼 유지 — buttons 0은 stale 드래그로 간주돼 취소됨 + buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, + }); + Object.defineProperty(event, 'pointerId', { value: init.pointerId }); + return event; +}; + +describe('GradientAxisOverlay 드래그 로직', () => { + let root: Root; + let host: HTMLDivElement; + let apply: ReturnType< + typeof vi.fn<(spec: GradientSpec, commit: boolean) => void> + >; + let selectStop: ReturnType void>>; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + apply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + selectStop = vi.fn<(index: number) => void>(); + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:idle', + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply, + }); + root.render( + , + ); + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + useGradientEditStore.getState().setSession(null); + }); + host.remove(); + }); + + const strip = () => host.querySelector('[role="slider"]') as HTMLElement; + const axisAnchor = (end: 'start' | 'end') => + host.querySelector(`[data-axis-anchor="${end}"]`) as HTMLElement; + const stopDot = (n: number) => + host.querySelector(`[aria-label="stop ${n}"]`) as HTMLElement; + + it('세션이 있으면 축 슬라이더·끝 앵커·스톱 점을 렌더한다', () => { + expect(strip()).toBeTruthy(); + expect(axisAnchor('start')).toBeTruthy(); + expect(axisAnchor('end')).toBeTruthy(); + expect(stopDot(1)).toBeTruthy(); + expect(stopDot(2)).toBeTruthy(); + }); + + it('축을 잡고 window에서 움직이면 각도 프리뷰·커밋이 적용된다', () => { + // 요소 중심 (200, 150), 각도 90 → 축은 수평 + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + clientX: 260, + clientY: 150, + }), + ); + }); + // 임계값 전에는 클릭(스톱 추가) 후보 — 아직 회전 아님 + expect(strip().style.cursor).toBe('grab'); + + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 1, + clientX: 260, + clientY: 90, + }), + ); + }); + expect(strip().style.cursor).toBe('grabbing'); + expect(apply).toHaveBeenCalled(); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).not.toBe(90); + + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, clientX: 260, clientY: 90 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.angle).not.toBe(90); + }); + + it('축 끝 앵커를 끌면 각도만 바뀐다', () => { + // end 앵커 위치: 선 끝 (300, 150) + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 3, + clientX: 300, + clientY: 150, + }), + ); + }); + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 3, + clientX: 200, + clientY: 60, + }), + ); + }); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).toBe(0); + expect(previewSpec.stops).toEqual(SPEC.stops); + + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 3, clientX: 200, clientY: 60 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.angle).toBe(0); + }); + + it('스톱 점을 끌면 축에 사영된 pos만 바뀌고 각도는 불변이다', () => { + act(() => { + stopDot(1).dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 2, + clientX: 100, + clientY: 150, + }), + ); + }); + expect(selectStop).toHaveBeenCalledWith(0); + + // 대각선 드래그 — 축(수평) 성분만 반영돼야 한다 + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 2, + clientX: 160, + clientY: 90, + }), + ); + }); + expect(apply).toHaveBeenCalled(); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).toBe(90); + expect(previewSpec.stops[0].pos).toBeCloseTo(0.3); + }); + + it('축 선을 클릭하면 그 위치에 스톱이 추가된다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 4, + clientX: 240, + clientY: 150, + }), + ); + }); + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 4, clientX: 240, clientY: 150 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.stops).toHaveLength(3); + expect(finalSpec.stops[1].pos).toBeCloseTo(0.7); + expect(finalSpec.stops[1].color).toBe('#ff0000'); + expect(selectStop).toHaveBeenCalledWith(1); + }); + + it('키보드 화살표로 각도가 커밋된다', () => { + act(() => { + strip().dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + }); + expect(apply).toHaveBeenCalledWith( + expect.objectContaining({ angle: 91 }), + true, + ); + }); + + it('축을 클릭하면 슬라이더가 포커스를 받는다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 7, + clientX: 260, + clientY: 150, + }), + ); + }); + expect(document.activeElement).toBe(strip()); + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 7, clientX: 260, clientY: 150 }), + ); + }); + }); + + it('드래그 도중 세션이 다른 상태로 교체되면 새 세션에 커밋하지 않는다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 5, + clientX: 260, + clientY: 150, + }), + ); + }); + + // 같은 요소의 입력(active) 상태 세션으로 교체 — 대기/입력 탭 전환 시나리오 + const activeApply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:active', + surface: 'background', + stateMode: 'active', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: activeApply, + }); + }); + + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 5, + clientX: 260, + clientY: 90, + }), + ); + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 5, clientX: 260, clientY: 90 }), + ); + }); + // 새 세션에는 프리뷰도 커밋도 가지 않는다 + expect(activeApply).not.toHaveBeenCalled(); + }); + + it('포인터 이벤트 사이에 세션이 A→B→새 A로 왕복해도 커밋하지 않는다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 6, + clientX: 260, + clientY: 150, + }), + ); + }); + + // 포인터 이벤트 없이 B(입력 상태) 경유 후 같은 key의 새 A 세션으로 복귀 + const newIdleApply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:active', + surface: 'background', + stateMode: 'active', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: vi.fn(), + }); + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:idle', + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: newIdleApply, + }); + }); + + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 6, + clientX: 260, + clientY: 90, + }), + ); + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 6, clientX: 260, clientY: 90 }), + ); + }); + // 세대 불일치로 드래그가 중단돼야 한다 — 원 세션·새 세션 모두 무커밋 + expect(newIdleApply).not.toHaveBeenCalled(); + expect(apply).not.toHaveBeenCalled(); + }); + + it('포인터 이벤트 사이에 세션이 A→null→새 A로 재개방돼도 커밋하지 않는다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 9, + clientX: 260, + clientY: 150, + }), + ); + }); + + const newIdleApply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + act(() => { + useGradientEditStore.getState().setSession(null); + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:idle', + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: newIdleApply, + }); + }); + + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 9, + clientX: 260, + clientY: 90, + }), + ); + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 9, clientX: 260, clientY: 90 }), + ); + }); + + expect(newIdleApply).not.toHaveBeenCalled(); + expect(apply).not.toHaveBeenCalled(); + }); + + it('batch idle 세션은 선택 전체 bounds 중심을 쓴다', () => { + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'batch' }, + sessionKey: 'batch:4key:backgroundColor:idle', + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply, + }); + root.render( + , + ); + }); + // key(100,100,200x100) + stat(400,400,60x60) → 전체 (100..460) 중심 + expect(strip().style.left).toBe('280px'); + expect(strip().style.top).toBe('280px'); + }); + + it('batch active 세션은 키·노브 bounds 중심만 쓴다', () => { + // active 편집 대상이 아닌 통계·그래프는 축 중심·자석 각도에서 제외 + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'batch' }, + sessionKey: 'batch:4key:backgroundColor:active', + surface: 'background', + stateMode: 'active', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply, + }); + root.render( + , + ); + }); + // 편집 대상은 키뿐 → 키(100,100,200x100) 중심 + expect(strip().style.left).toBe('200px'); + expect(strip().style.top).toBe('150px'); + }); + + it('A→B→새 A 왕복 후 pointercancel도 새 세션에 stale 롤백을 보내지 않는다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 8, + clientX: 260, + clientY: 150, + }), + ); + }); + + const newIdleApply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:active', + surface: 'background', + stateMode: 'active', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: vi.fn(), + }); + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + sessionKey: 'key:4key:0:backgroundColor:idle', + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop, + apply: newIdleApply, + }); + }); + + act(() => { + window.dispatchEvent( + pointerEvent('pointercancel', { + pointerId: 8, + clientX: 260, + clientY: 150, + }), + ); + }); + // 취소 경로도 세대 검사 — 교체된 세션에 startSpec 복원 preview 미전달 + expect(newIdleApply).not.toHaveBeenCalled(); + expect(apply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/__tests__/gradientColorState.test.tsx b/src/renderer/__tests__/gradientColorState.test.tsx new file mode 100644 index 00000000..e1ea1f2d --- /dev/null +++ b/src/renderer/__tests__/gradientColorState.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import React, { act, useEffect, type ReactElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import type { ColorModeValue, ColorPair, GradientSpec } from '@src/types/color'; + +type GradientState = ReturnType; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const oldGradient: GradientSpec = { + angle: 17, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: 'rgba(255,0,0,0)', pos: 1 }, + ], +}; + +const threeStops: GradientSpec = { + angle: 90, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: '#00ff00', pos: 0.5 }, + { color: '#0000ff', pos: 1 }, + ], +}; + +describe('useGradientColorState 편집 수명', () => { + let root: Root; + let host: HTMLDivElement; + let stateCapture: { current: GradientState | null }; + let onCommit: ReturnType void>>; + + const Harness = ({ pair }: { pair: ColorPair }) => { + const state = useGradientColorState({ + pair, + fallbackColor: '#ffffff', + contextKey: 'key:4key:0:backgroundColor:idle', + onCommit, + }); + useEffect(() => { + stateCapture.current = state; + }, [state]); + return null; + }; + + const latest = () => { + if (!stateCapture.current) throw new Error('gradient state not captured'); + return stateCapture.current; + }; + + const changeFormat = (format: 'solid' | 'gradient') => { + const footer = latest().footerSlot as ReactElement<{ + onFormatChange: (next: 'solid' | 'gradient') => void; + }>; + act(() => footer.props.onFormatChange(format)); + }; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + stateCapture = { current: null }; + onCommit = vi.fn<(value: ColorModeValue) => void>(); + act(() => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + useGridSelectionStore.getState().clearSelection(); + }); + host.remove(); + }); + + it('같은 선택에서는 형식 왕복 spec을 복원하되 선택 수명이 끝나면 폐기한다', () => { + act(() => + root.render( + , + ), + ); + + changeFormat('solid'); + act(() => root.render()); + changeFormat('gradient'); + expect(onCommit).toHaveBeenLastCalledWith({ + mode: 'gradient', + spec: oldGradient, + }); + + act(() => + root.render( + , + ), + ); + changeFormat('solid'); + act(() => root.render()); + + act(() => useGridSelectionStore.getState().clearSelection()); + act(() => + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]), + ); + act(() => root.render()); + changeFormat('gradient'); + + expect(onCommit).toHaveBeenLastCalledWith({ + mode: 'gradient', + spec: { + angle: 180, + stops: [ + { color: '#0000ff', pos: 0 }, + { color: 'rgba(0,0,255,0)', pos: 1 }, + ], + }, + }); + }); + + it('외부 spec 축소 뒤 선택 스톱을 마지막 유효 스톱으로 강등한다', () => { + act(() => + root.render( + , + ), + ); + const header = latest().headerSlot as ReactElement<{ + onSelectStop: (index: number) => void; + }>; + act(() => header.props.onSelectStop(2)); + + const twoStops: GradientSpec = { + ...threeStops, + stops: [threeStops.stops[0], threeStops.stops[1]], + }; + act(() => + root.render(), + ); + act(() => latest().handlePickerColorChange('#abcdef', true)); + + expect(onCommit).toHaveBeenLastCalledWith({ + mode: 'gradient', + spec: { + ...twoStops, + stops: [twoStops.stops[0], { ...twoStops.stops[1], color: '#abcdef' }], + }, + }); + }); + + it('스톱 추가 직후 새 스톱을 선택한다', () => { + act(() => + root.render( + , + ), + ); + const beforeAdd = latest().headerSlot as ReactElement<{ + selectedIndex: number; + onSelectStop: (index: number) => void; + onSpecChangeComplete: (spec: GradientSpec) => void; + }>; + + act(() => { + beforeAdd.props.onSelectStop(2); + beforeAdd.props.onSpecChangeComplete(threeStops); + }); + act(() => + root.render( + , + ), + ); + + const afterAdd = latest().headerSlot as ReactElement<{ + selectedIndex: number; + }>; + expect(afterAdd.props.selectedIndex).toBe(2); + }); +}); diff --git a/src/renderer/__tests__/gradientEditStore.test.tsx b/src/renderer/__tests__/gradientEditStore.test.tsx new file mode 100644 index 00000000..ba1a58bb --- /dev/null +++ b/src/renderer/__tests__/gradientEditStore.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment jsdom +/** + * 그라데이션 편집 세션 스토어 테스트 + * 소유권 세대 규칙(같은 key 재발행 유지, 교체·왕복 증가)과 + * 일시 페인트 구독(useGradientPreviewSpec)의 대상 격리 검증 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + useGradientEditStore, + useGradientPreviewSpec, + useGradientPreviewSession, + type GradientEditSession, + type GradientPreviewSurface, +} from '@stores/grid/useGradientEditStore'; +import type { GradientSpec } from '@src/types/color'; + +const SPEC: GradientSpec = { + angle: 90, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: '#0000ff', pos: 1 }, + ], +}; + +const makeSession = ( + sessionKey: string, + overrides: Partial = {}, +): GradientEditSession => ({ + anchor: { kind: 'key', index: 0 }, + sessionKey, + surface: 'background', + stateMode: 'idle', + spec: SPEC, + selectedIndex: 0, + selectStop: vi.fn(), + apply: vi.fn(), + ...overrides, +}); + +describe('소유권 세대 규칙', () => { + beforeEach(() => { + useGradientEditStore.getState().setSession(null); + }); + + it('세션 종료와 같은 key 재개방은 각각 소유권 세대를 증가시킨다', () => { + const store = useGradientEditStore; + store.getState().setSession(makeSession('A')); + const generation = store.getState().generation; + store.getState().setSession(null); + expect(store.getState().generation).toBe(generation + 1); + store.getState().setSession(makeSession('A')); + expect(store.getState().generation).toBe(generation + 2); + }); + + it('key 교체와 A→B→A 왕복은 세대를 증가시킨다', () => { + const store = useGradientEditStore; + store.getState().setSession(makeSession('A')); + const g0 = store.getState().generation; + store.getState().setSession(makeSession('B')); + expect(store.getState().generation).toBe(g0 + 1); + store.getState().setSession(makeSession('A')); + expect(store.getState().generation).toBe(g0 + 2); + }); + + it('patchSession은 같은 key의 spec만 갱신하고 세대를 유지한다', () => { + const store = useGradientEditStore; + store.getState().setSession(makeSession('A')); + const generation = store.getState().generation; + const nextSpec: GradientSpec = { ...SPEC, angle: 45 }; + + store.getState().patchSession('A', { spec: nextSpec, selectedIndex: 1 }); + expect(store.getState().session?.spec.angle).toBe(45); + expect(store.getState().session?.selectedIndex).toBe(1); + expect(store.getState().generation).toBe(generation); + + // key 불일치 패치는 무시 + store.getState().patchSession('B', { spec: SPEC }); + expect(store.getState().session?.spec.angle).toBe(45); + }); +}); + +describe('useGradientPreviewSpec 대상 격리', () => { + let root: Root; + let host: HTMLDivElement; + + const Probe = ({ + kind, + index, + surface, + inBatch = false, + }: { + kind: 'key' | 'stat' | 'graph' | 'knob'; + index: number; + surface: GradientPreviewSurface; + inBatch?: boolean; + }) => { + const spec = useGradientPreviewSpec(kind, index, surface, inBatch); + const session = useGradientPreviewSession(kind, index, inBatch); + return ( +
+ ); + }; + + const probeAngle = () => + (host.querySelector('[data-testid="probe"]') as HTMLElement).dataset.angle; + const probeStateMode = () => + (host.querySelector('[data-testid="probe"]') as HTMLElement).dataset + .stateMode; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => { + root.unmount(); + useGradientEditStore.getState().setSession(null); + }); + host.remove(); + }); + + it('요소·표면이 모두 일치할 때만 spec을 반환한다', () => { + act(() => { + useGradientEditStore + .getState() + .setSession(makeSession('A', { anchor: { kind: 'key', index: 2 } })); + root.render(); + }); + expect(probeAngle()).toBe('90'); + expect(probeStateMode()).toBe('idle'); + + // 표면 불일치 + act(() => { + root.render(); + }); + expect(probeAngle()).toBe('none'); + + // 인덱스 불일치 + act(() => { + root.render(); + }); + expect(probeAngle()).toBe('none'); + + // 종류 불일치 + act(() => { + root.render(); + }); + expect(probeAngle()).toBe('none'); + }); + + it('입력 세션의 상태도 대상 요소에 함께 전달한다', () => { + act(() => { + useGradientEditStore.getState().setSession( + makeSession('active', { + anchor: { kind: 'key', index: 2 }, + stateMode: 'active', + }), + ); + root.render(); + }); + + expect(probeAngle()).toBe('90'); + expect(probeStateMode()).toBe('active'); + }); + + it('batch 세션은 선택된 요소에만 반영된다', () => { + act(() => { + useGradientEditStore + .getState() + .setSession(makeSession('batch', { anchor: { kind: 'batch' } })); + root.render(); + }); + expect(probeAngle()).toBe('90'); + + act(() => { + root.render( + , + ); + }); + expect(probeAngle()).toBe('none'); + }); + + it('active batch 세션은 키·노브에만 반영된다', () => { + // 저장 라우팅과 동일 규칙 — 입력 상태가 없는 통계·그래프에는 미전달 + act(() => { + useGradientEditStore.getState().setSession( + makeSession('batch-active', { + anchor: { kind: 'batch' }, + stateMode: 'active', + }), + ); + root.render(); + }); + expect(probeAngle()).toBe('90'); + expect(probeStateMode()).toBe('active'); + + act(() => { + root.render(); + }); + expect(probeAngle()).toBe('90'); + + act(() => { + root.render(); + }); + expect(probeAngle()).toBe('none'); + expect(probeStateMode()).toBe('none'); + + act(() => { + root.render( + , + ); + }); + expect(probeAngle()).toBe('none'); + expect(probeStateMode()).toBe('none'); + }); + + it('idle batch 세션은 통계·그래프에도 반영된다', () => { + act(() => { + useGradientEditStore + .getState() + .setSession(makeSession('batch-idle', { anchor: { kind: 'batch' } })); + root.render(); + }); + expect(probeAngle()).toBe('90'); + + act(() => { + root.render( + , + ); + }); + expect(probeAngle()).toBe('90'); + }); +}); diff --git a/src/renderer/__tests__/gridCanvasDeletionSelection.test.ts b/src/renderer/__tests__/gridCanvasDeletionSelection.test.ts new file mode 100644 index 00000000..93256793 --- /dev/null +++ b/src/renderer/__tests__/gridCanvasDeletionSelection.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGridCanvasActions } from '@hooks/Grid/useGridCanvasActions'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import type { StatItemPosition } from '@src/types/key/statItems'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { KnobItemPosition } from '@src/types/key/knobs'; + +vi.mock('@stores/data/useHistoryStore', () => ({ + useHistoryStore: { + getState: () => ({ pushState: vi.fn() }), + }, +})); + +const makeStat = (): StatItemPosition => ({ + ...createDefaultKeyPosition(), + statType: 'kps', +}); + +const makeGraph = (): GraphItemPosition => ({ + ...createDefaultKeyPosition(), + statType: 'kps', + graphType: 'line', + graphSpeed: 1000, + graphColor: '#ffffff', +}); + +const makeKnob = (): KnobItemPosition => ({ + ...createDefaultKeyPosition(), + axisId: '', + sensitivity: 1, + reverse: false, +}); + +describe('캔버스 요소 삭제 선택 정리', () => { + const originalApi = window.api; + const updateStatPositions = vi.fn(); + const updateGraphPositions = vi.fn(); + const updateKnobPositions = vi.fn(); + + beforeEach(() => { + updateStatPositions.mockResolvedValue(undefined); + updateGraphPositions.mockResolvedValue(undefined); + updateKnobPositions.mockResolvedValue(undefined); + window.api = { + statItems: { updatePositions: updateStatPositions }, + graphItems: { updatePositions: updateGraphPositions }, + knobItems: { updatePositions: updateKnobPositions }, + } as unknown as Window['api']; + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': [] }, + positions: { '4key': [] }, + isBootstrapped: false, + }); + useStatItemStore.setState({ + positions: { '4key': [makeStat(), makeStat(), makeStat()] }, + isLocalUpdateInProgress: false, + }); + useGraphItemStore.setState({ + positions: { '4key': [makeGraph(), makeGraph(), makeGraph()] }, + isLocalUpdateInProgress: false, + }); + useKnobItemStore.setState({ + positions: { '4key': [makeKnob(), makeKnob(), makeKnob()] }, + isLocalUpdateInProgress: false, + }); + useGridSelectionStore.getState().clearSelection(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.api = originalApi; + useGridSelectionStore.getState().clearSelection(); + }); + + it('통계 실제 삭제 핸들러가 선택을 제거하고 후속 인덱스를 당긴다', () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'stat', id: 'stat-0', index: 0 }, + { type: 'stat', id: 'stat-2', index: 2 }, + { type: 'graph', id: 'graph-1', index: 1 }, + ]); + + useGridCanvasActions('4key').deleteStatAtIndex(0); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'stat', id: 'stat-1', index: 1 }, + { type: 'graph', id: 'graph-1', index: 1 }, + ]); + expect(useStatItemStore.getState().positions['4key']).toHaveLength(2); + }); + + it('그래프 실제 삭제 핸들러가 선택을 제거하고 후속 인덱스를 당긴다', () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'graph', id: 'graph-0', index: 0 }, + { type: 'graph', id: 'graph-2', index: 2 }, + { type: 'knob', id: 'knob-1', index: 1 }, + ]); + + useGridCanvasActions('4key').deleteGraphAtIndex(0); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'graph', id: 'graph-1', index: 1 }, + { type: 'knob', id: 'knob-1', index: 1 }, + ]); + expect(useGraphItemStore.getState().positions['4key']).toHaveLength(2); + }); + + it('노브 실제 삭제 핸들러가 선택을 제거하고 후속 인덱스를 당긴다', () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'knob', id: 'knob-0', index: 0 }, + { type: 'knob', id: 'knob-2', index: 2 }, + { type: 'key', id: 'key-1', index: 1 }, + ]); + + useGridCanvasActions('4key').deleteKnobAtIndex(0); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'knob', id: 'knob-1', index: 1 }, + { type: 'key', id: 'key-1', index: 1 }, + ]); + expect(useKnobItemStore.getState().positions['4key']).toHaveLength(2); + }); +}); diff --git a/src/renderer/__tests__/keyDeletionSelection.test.tsx b/src/renderer/__tests__/keyDeletionSelection.test.tsx new file mode 100644 index 00000000..e11b892c --- /dev/null +++ b/src/renderer/__tests__/keyDeletionSelection.test.tsx @@ -0,0 +1,309 @@ +// @vitest-environment jsdom +import React, { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useKeyManager } from '@hooks/useKeyManager'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { + useHistoryStore, + type HistoryState, +} from '@stores/data/useHistoryStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import { persistRestoredState } from '@src/renderer/editor/runtime/editorSnapshot'; + +vi.mock('@src/renderer/editor/runtime/editorSnapshot', () => ({ + pushCurrentStateToHistory: vi.fn(), + applyRestoredStateToStores: vi.fn(), + applyRestoredPluginElements: vi.fn(), + persistRestoredState: vi.fn(), +})); + +vi.mock('@src/renderer/editor/runtime/persistState', () => ({ + persistPositionsWithSync: vi.fn(), + persistMappingsAndPositions: vi.fn(), + persistPositions: vi.fn(), + persistPositionsWithFlag: vi.fn(), +})); + +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { + flush: vi.fn(), + getState: vi.fn(() => ({ revision: 1 })), + sync: vi.fn(), + }, +})); + +vi.mock('@api/pluginDisplayElements', () => ({ + setUndoRedoInProgress: vi.fn(), +})); + +vi.mock('@plugins/runtime/displayElement/displayElementApi', () => ({ + removeDisplayElementsInternal: vi.fn(), +})); + +type KeyManager = ReturnType; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe('키 삭제 선택 정리', () => { + const originalApi = window.api; + const resetMode = vi.fn(); + let root: Root; + let host: HTMLDivElement; + let manager: KeyManager | null; + + const Harness = () => { + const value = useKeyManager(); + useEffect(() => { + manager = value; + }, [value]); + return null; + }; + + const latest = () => { + if (!manager) throw new Error('key manager not captured'); + return manager; + }; + + const restoredState = (): HistoryState => ({ + keyMappings: { '4key': ['A', 'B', 'C', 'D'] }, + positions: { + '4key': [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }, + statPositions: { '4key': [] }, + graphPositions: { '4key': [] }, + knobPositions: { '4key': [] }, + pluginElements: [], + layerGroups: { '4key': [] }, + keyCounters: {}, + customTabs: [], + selectedKeyType: '4key', + }); + + beforeEach(() => { + resetMode.mockReset().mockResolvedValue({ success: true }); + window.api = { + keys: { resetMode }, + } as unknown as Window['api']; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + manager = null; + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A', 'B', 'C'] }, + positions: { + '4key': [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }, + isBootstrapped: false, + isLocalUpdateInProgress: false, + }); + useGridSelectionStore.getState().clearSelection(); + useHistoryStore.setState({ past: [], future: [] }); + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + useGridSelectionStore.getState().clearSelection(); + useHistoryStore.setState({ past: [], future: [] }); + window.api = originalApi; + }); + + it('실제 삭제 핸들러가 삭제된 키의 grid 선택을 제거한다', () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + act(() => root.render()); + + act(() => latest().handleDeleteKey(0)); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + expect(useKeyStore.getState().keyMappings['4key']).toEqual(['B', 'C']); + }); + + it('삭제 뒤 키 인덱스를 당기고 다른 요소 선택은 보존한다', () => { + useGridSelectionStore.getState().setSelectedElements([ + { type: 'key', id: 'key-2', index: 2 }, + { type: 'stat', id: 'stat-0', index: 0 }, + ]); + act(() => root.render()); + + act(() => latest().handleDeleteKey(0)); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'key', id: 'key-1', index: 1 }, + { type: 'stat', id: 'stat-0', index: 0 }, + ]); + }); + + it('현재 탭 리셋 성공 시 grid 선택을 해제한다', async () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + act(() => root.render()); + + await act(async () => latest().handleResetCurrentMode()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + }); + + it('현재 탭 리셋 실패 시 grid 선택을 보존한다', async () => { + resetMode.mockResolvedValueOnce({ success: false }); + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + act(() => root.render()); + + await act(async () => latest().handleResetCurrentMode()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + }); + + it('undo로 선택된 종류의 배열 길이가 바뀌면 선택을 해제한다', async () => { + useKeyStore.setState({ + keyMappings: { '4key': ['B', 'C', 'D'] }, + positions: { + '4key': [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }, + }); + useHistoryStore.setState({ past: [restoredState()], future: [] }); + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + act(() => root.render()); + + await act(async () => latest().handleUndo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + }); + + it('redo로 선택된 종류의 배열 길이가 바뀌면 선택을 해제한다', async () => { + useKeyStore.setState({ + keyMappings: { '4key': ['B', 'C', 'D'] }, + positions: { + '4key': [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }, + }); + useHistoryStore.setState({ past: [], future: [restoredState()] }); + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + act(() => root.render()); + + await act(async () => latest().handleRedo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + }); + + it('길이가 같은 스타일 undo는 선택을 보존한다', async () => { + const target = restoredState(); + target.keyMappings = { '4key': ['A', 'B', 'C'] }; + target.positions = { + '4key': [ + { ...createDefaultKeyPosition(), backgroundColor: '#123456' }, + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }; + useHistoryStore.setState({ past: [target], future: [] }); + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + act(() => root.render()); + + await act(async () => latest().handleUndo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + }); + + it('같은 개수의 프리셋 교체 undo와 redo는 모두 선택을 해제한다', async () => { + const target = restoredState(); + target.keyMappings = { '4key': ['D', 'E', 'F'] }; + target.positions = { + '4key': [ + createDefaultKeyPosition(), + createDefaultKeyPosition(), + createDefaultKeyPosition(), + ], + }; + target.invalidatesGridSelection = true; + useHistoryStore.setState({ past: [target], future: [] }); + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + act(() => root.render()); + + await act(async () => latest().handleUndo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + expect( + useHistoryStore.getState().future.at(-1)?.invalidatesGridSelection, + ).toBe(true); + + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + await act(async () => latest().handleRedo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + expect( + useHistoryStore.getState().past.at(-1)?.invalidatesGridSelection, + ).toBe(true); + }); + + it('끝에 요소만 복원하는 undo는 앞쪽 선택을 보존한다', async () => { + useHistoryStore.setState({ past: [restoredState()], future: [] }); + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + act(() => root.render()); + + await act(async () => latest().handleUndo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + }); + + it('undo 저장 실패 시 선택을 보존한다', async () => { + vi.mocked(persistRestoredState).mockRejectedValueOnce( + new Error('injected failure'), + ); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const target = restoredState(); + target.invalidatesGridSelection = true; + useHistoryStore.setState({ past: [target], future: [] }); + const selection = [{ type: 'key' as const, id: 'key-0', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + act(() => root.render()); + + await act(async () => latest().handleUndo()); + + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + }); +}); diff --git a/src/renderer/__tests__/panelAnchoredPopupPosition.test.ts b/src/renderer/__tests__/panelAnchoredPopupPosition.test.ts new file mode 100644 index 00000000..b437e948 --- /dev/null +++ b/src/renderer/__tests__/panelAnchoredPopupPosition.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getPanelAnchoredPopupPosition } from '@hooks/ui/usePanelAnchoredPopupPosition'; + +describe('사이드 패널 팝업 앵커 정렬', () => { + it('팝업을 트리거 행 세로 중앙에 맞추고 X는 패널 왼쪽에 도킹한다', () => { + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 800, top: 100, width: 180, height: 600 }, + anchorCenterY: 240, + popupWidth: 200, + popupHeight: 100, + viewportWidth: 1000, + viewportHeight: 800, + }), + // 팝업 중심(190+50) == 앵커 중심(240) + ).toEqual({ x: 595, y: 190 }); + }); + + it('앵커가 없으면 패널 세로 중앙으로 폴백한다', () => { + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 800, top: 100, width: 180, height: 600 }, + anchorCenterY: null, + popupWidth: 200, + popupHeight: 100, + viewportWidth: 1000, + viewportHeight: 800, + }), + ).toEqual({ x: 595, y: 350 }); + }); + + it('앵커가 위쪽이면 팝업 상단을 패널 상단에 맞춰 멈춘다', () => { + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 800, top: 100, width: 180, height: 600 }, + anchorCenterY: 120, + popupWidth: 200, + popupHeight: 200, + viewportWidth: 1000, + viewportHeight: 800, + }), + // 앵커 중심 120 기준이면 20이지만 패널 top(100)+갭(5)에서 멈춤 + ).toEqual({ x: 595, y: 105 }); + }); + + it('앵커가 아래쪽이면 팝업 하단을 패널 하단에 맞춰 멈춘다', () => { + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 800, top: 100, width: 180, height: 400 }, + anchorCenterY: 450, + popupWidth: 200, + popupHeight: 200, + viewportWidth: 1000, + viewportHeight: 800, + }), + // 앵커 중심 450 기준이면 350이지만 패널 bottom(500)-갭(5)에서 멈춤 + ).toEqual({ x: 595, y: 295 }); + }); + + it('화면 경계에서는 팝업 전체가 보이도록 클램프한다', () => { + // 앵커가 화면 위로 벗어난 경우 + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 100, top: -50, width: 180, height: 100 }, + anchorCenterY: -30, + popupWidth: 200, + popupHeight: 200, + viewportWidth: 1000, + viewportHeight: 800, + }), + ).toEqual({ x: 5, y: 5 }); + + // 앵커가 화면 하단 근처라 팝업이 잘리는 경우 + expect( + getPanelAnchoredPopupPosition({ + panelRect: { left: 1200, top: 700, width: 180, height: 200 }, + anchorCenterY: 780, + popupWidth: 200, + popupHeight: 300, + viewportWidth: 1000, + viewportHeight: 800, + }), + ).toEqual({ x: 795, y: 495 }); + }); + + it('패널 외부 피커 3종이 같은 앵커 정렬 계산을 사용한다', () => { + const files = ['ColorPicker.tsx', 'ImagePicker.tsx', 'ShadowPicker.tsx']; + + for (const file of files) { + const source = readFileSync( + resolve( + process.cwd(), + 'src/renderer/components/main/Modal/content/pickers', + file, + ), + 'utf8', + ); + expect(source).toContain('usePanelAnchoredPopupPosition'); + expect(source).toContain('referenceRef,'); + expect(source).not.toContain('panelRect.bottom'); + } + }); +}); diff --git a/src/renderer/__tests__/presetLoadSelection.test.tsx b/src/renderer/__tests__/presetLoadSelection.test.tsx new file mode 100644 index 00000000..c7730cd5 --- /dev/null +++ b/src/renderer/__tests__/presetLoadSelection.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import SettingTool from '@components/main/Tool/SettingTool'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; + +const popup = vi.hoisted(() => ({ + onSelect: null as null | ((id: string) => Promise), +})); + +const history = vi.hoisted(() => ({ + pushState: vi.fn(), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@api/modules/obsApi', () => ({ + obsApi: { + status: vi.fn().mockResolvedValue({ running: false }), + onStatus: vi.fn(() => vi.fn()), + }, +})); + +vi.mock('@stores/data/useHistoryStore', () => ({ + useHistoryStore: (selector: (state: { pushState: () => void }) => unknown) => + selector({ pushState: history.pushState }), +})); + +vi.mock('@components/main/Modal/ListPopup', () => ({ + default: (props: { onSelect: (id: string) => Promise }) => { + popup.onSelect = props.onSelect; + return null; + }, +})); + +vi.mock('@components/main/Modal/FloatingTooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@components/main/Modal/TooltipGroup', () => ({ + TooltipGroup: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@assets/svgs/folder.svg', () => ({ default: () => null })); +vi.mock('@assets/svgs/setting.svg', () => ({ default: () => null })); +vi.mock('@assets/svgs/chevron-down.svg', () => ({ default: () => null })); +vi.mock('@assets/svgs/turn_arrow.svg', () => ({ default: () => null })); + +describe('프리셋 로드 선택 수명', () => { + const originalApi = window.api; + const load = vi.fn(); + const loadTab = vi.fn(); + let root: Root; + let host: HTMLDivElement; + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + load.mockReset().mockResolvedValue({ success: true }); + loadTab.mockReset().mockResolvedValue({ success: true }); + window.api = { + overlay: { + get: vi.fn().mockResolvedValue({ visible: true }), + onVisibility: vi.fn(() => vi.fn()), + }, + presets: { + load, + loadTab, + }, + keys: { + getCounters: vi.fn().mockResolvedValue({}), + }, + } as unknown as Window['api']; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + popup.onSelect = null; + history.pushState.mockReset(); + useGridSelectionStore.getState().clearSelection(); + await act(async () => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + window.api = originalApi; + useGridSelectionStore.getState().clearSelection(); + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + }); + + const selectKey = () => { + useGridSelectionStore + .getState() + .setSelectedElements([{ type: 'key', id: 'key-0', index: 0 }]); + }; + + const invokePopup = async (id: string) => { + if (!popup.onSelect) throw new Error('popup handler not captured'); + await act(async () => popup.onSelect?.(id)); + }; + + it('전체 프리셋 로드 성공 시 선택을 해제한다', async () => { + selectKey(); + + await invokePopup('import-all'); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + expect(history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ invalidatesGridSelection: true }), + ); + }); + + it('탭 프리셋 로드 성공 시 선택을 해제한다', async () => { + selectKey(); + + await invokePopup('import-tab'); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([]); + expect(history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ invalidatesGridSelection: true }), + ); + }); + + it('프리셋 로드 실패 시 선택을 보존한다', async () => { + load.mockResolvedValueOnce({ success: false }); + selectKey(); + + await invokePopup('import-all'); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'key', id: 'key-0', index: 0 }, + ]); + }); + + it('프리셋 로드 예외 시에도 선택을 보존한다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + load.mockRejectedValueOnce(new Error('injected failure')); + selectKey(); + + await invokePopup('import-all'); + + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'key', id: 'key-0', index: 0 }, + ]); + }); +}); diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx new file mode 100644 index 00000000..57836d94 --- /dev/null +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -0,0 +1,312 @@ +// @vitest-environment jsdom +// 렌더 DOM 계약 — data 속성과 --dmn-* fallback 변수가 실제 컴포넌트 루트에 실리는지 고정 +// (elementShadowContract 등 기존 계약 테스트는 유틸 반환값·CSS 텍스트만 검증) +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Key } from '@components/shared/Key'; +import StatItem from '@components/overlay/counters/StatItem'; +import OverlayKnobItem from '@components/overlay/counters/OverlayKnobItem'; +import GraphPanel from '@components/shared/GraphPanel'; +import { resetAllKeySignals, setKeyActive } from '@stores/signals/keySignals'; +import { addAxisDelta, resetAllAxisSignals } from '@stores/signals/axisSignals'; +import { + computeKeyElementStyles, + type KeyElementPosition, +} from '@hooks/overlay/useKeyElementStyles'; +import { + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_ACTIVE_SHADOW, + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_HAIRLINE, + DEFAULT_ELEMENT_SHADOW, +} from '@utils/core/elementDefaults'; +import { + elementShadowToCss, + type ElementShadowSpec, +} from '@src/types/key/shadows'; + +const basePosition: KeyElementPosition = { + dx: 4, + dy: 8, + width: 60, + height: 60, +}; + +const customShadow: ElementShadowSpec = { + enabled: true, + color: 'rgba(12, 34, 56, 0.45)', + offsetX: -2, + offsetY: 7, + blur: 18, +}; + +// 유틸이 계산한 --dmn-* 변수 전부가 요소 style에 실렸는지 비교 +const expectCustomPropsMatch = ( + el: HTMLElement, + style: React.CSSProperties, +) => { + const entries = Object.entries(style as Record).filter( + ([name, value]) => name.startsWith('--') && value != null, + ); + expect(entries.length).toBeGreaterThan(0); + for (const [name, value] of entries) { + expect(el.style.getPropertyValue(name), name).toBe(String(value)); + } +}; + +describe('렌더 DOM 계약', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + resetAllKeySignals(); + resetAllAxisSignals(); + }); + + describe('공유 Key', () => { + const renderKey = (position: KeyElementPosition, globalKey: string) => { + act(() => { + root.render( + , + ); + }); + return host.querySelector('[data-key-element="true"]'); + }; + + it('루트에 data 속성과 fallback 변수를 싣는다', () => { + const el = renderKey(basePosition, 'KeyA'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('data-state')).toBe('inactive'); + // 일반 모드는 인라인 boxShadow 선언 없이 변수만 제공 + expect(el!.style.boxShadow).toBe(''); + expect(el!.style.getPropertyValue('--dmn-key-shadow-default')).toBe( + DEFAULT_ELEMENT_SHADOW, + ); + expect(el!.style.getPropertyValue('--dmn-key-bg-default')).toBe( + DEFAULT_ELEMENT_BG, + ); + expectCustomPropsMatch( + el!, + computeKeyElementStyles({ + position: basePosition, + active: false, + label: 'A', + }).keyStyle, + ); + }); + + it('키 시그널에 따라 data-state와 상태 변수가 바뀐다', () => { + const el = renderKey(basePosition, 'KeyB'); + + act(() => setKeyActive('KeyB', true)); + expect(el!.getAttribute('data-state')).toBe('active'); + expect(el!.style.getPropertyValue('--dmn-key-shadow-default')).toBe( + DEFAULT_ELEMENT_ACTIVE_SHADOW, + ); + expect(el!.style.getPropertyValue('--dmn-key-bg-default')).toBe( + DEFAULT_ELEMENT_ACTIVE_BG, + ); + + act(() => setKeyActive('KeyB', false)); + expect(el!.getAttribute('data-state')).toBe('inactive'); + expect(el!.style.getPropertyValue('--dmn-key-shadow-default')).toBe( + DEFAULT_ELEMENT_SHADOW, + ); + }); + + it('사용자 지정 그림자 변수를 그대로 싣는다', () => { + const position: KeyElementPosition = { + ...basePosition, + shadow: customShadow, + }; + const el = renderKey(position, 'KeyC'); + expect(el!.style.getPropertyValue('--dmn-key-shadow-default')).toBe( + elementShadowToCss(customShadow), + ); + expectCustomPropsMatch( + el!, + computeKeyElementStyles({ position, active: false, label: 'A' }) + .keyStyle, + ); + }); + + it('useInlineStyles면 변수 대신 인라인 선언으로 승격된다', () => { + const el = renderKey( + { ...basePosition, useInlineStyles: true, shadow: customShadow }, + 'KeyD', + ); + expect(el!.style.getPropertyValue('--dmn-key-shadow-default')).toBe(''); + expect(el!.style.boxShadow).toBe(elementShadowToCss(customShadow)); + expect(el!.style.backgroundColor).toBe(DEFAULT_ELEMENT_BG); + }); + + it('이미지 키 루트 배경은 기존처럼 상태 전환된다', () => { + const position: KeyElementPosition = { + ...basePosition, + activeImage: 'data:image/png;base64,aW1hZ2U=', + activeBackgroundColor: 'rgba(121, 121, 121, 0.9)', + }; + const el = renderKey(position, 'KeyImageActiveBg'); + + // 누르면 명시 입력 배경색이 루트에서 전환 + act(() => setKeyActive('KeyImageActiveBg', true)); + expect(el!.style.getPropertyValue('--dmn-key-bg-default')).toBe( + 'rgba(121, 121, 121, 0.9)', + ); + }); + + it('입력 배경색 없는 이미지 키는 눌리면 몸체가 투명으로 전환된다', () => { + const position: KeyElementPosition = { + ...basePosition, + activeImage: 'data:image/png;base64,aW1hZ2U=', + }; + const el = renderKey(position, 'KeyImageNoBg'); + + act(() => setKeyActive('KeyImageNoBg', true)); + // 이미지가 전부 — 링만 허공에 뜨는 원 계약 + expect(el!.style.getPropertyValue('--dmn-key-bg-default')).toBe( + 'transparent', + ); + }); + }); + + it('입력 이미지가 없으면 대기 이미지를 감광해 재사용한다', () => { + const position: KeyElementPosition = { + ...basePosition, + inactiveImage: 'data:image/png;base64,aW1hZ2U=', + }; + const idle = computeKeyElementStyles({ + position, + active: false, + label: 'A', + }); + const active = computeKeyElementStyles({ + position, + active: true, + label: 'A', + }); + + expect(idle.imageStyle.filter).toBe('none'); + // activeImage 부재 시 대기 이미지 + brightness 감광이 눌림 표현 + expect(active.currentImageSrc).toBe(idle.currentImageSrc); + expect(active.imageStyle.filter).toBe('brightness(0.62)'); + }); + + describe('오버레이 StatItem', () => { + const renderStat = (active: boolean) => { + act(() => { + root.render( + , + ); + }); + return host.querySelector('[data-key-element="true"]'); + }; + + it('data 속성과 상태별 변수를 싣는다', () => { + const el = renderStat(false); + expect(el).not.toBeNull(); + expect(el!.getAttribute('data-state')).toBe('inactive'); + expectCustomPropsMatch( + el!, + computeKeyElementStyles({ + position: basePosition, + active: false, + label: 'KPS', + }).keyStyle, + ); + + const activeEl = renderStat(true); + expect(activeEl!.getAttribute('data-state')).toBe('active'); + expect(activeEl!.style.getPropertyValue('--dmn-key-shadow-default')).toBe( + DEFAULT_ELEMENT_ACTIVE_SHADOW, + ); + }); + }); + + describe('오버레이 KnobItem', () => { + it('data 속성·노브 변수를 싣고 회전 입력이 active로 전환한다', () => { + act(() => { + root.render( + , + ); + }); + const el = host.querySelector('[data-knob-element="true"]'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('data-knob-state')).toBe('inactive'); + expect(el!.style.getPropertyValue('--dmn-knob-bg-default')).toBe( + DEFAULT_ELEMENT_BG, + ); + expect(el!.style.getPropertyValue('--dmn-knob-shadow-default')).toBe( + DEFAULT_ELEMENT_SHADOW, + ); + expect(el!.style.getPropertyValue('--dmn-knob-radius-default')).toBe( + '50%', + ); + expect(el!.style.getPropertyValue('--dmn-knob-border-default')).toBe( + 'none', + ); + expect(el!.style.getPropertyValue('--dmn-knob-padding-default')).toBe( + '0px', + ); + expect(el!.style.getPropertyValue('--dmn-knob-indicator-default')).toBe( + DEFAULT_ELEMENT_FONT, + ); + expect(host.querySelector('[data-knob-indicator="true"]')).not.toBeNull(); + + // 축 시그널 델타 → active 전환 + 회전 각 반영 (0.25회전 = 90deg) + act(() => addAxisDelta('axis-render-test', 0.25)); + expect(el!.getAttribute('data-knob-state')).toBe('active'); + expect(el!.style.getPropertyValue('--dmn-knob-bg-default')).toBe( + DEFAULT_ELEMENT_ACTIVE_BG, + ); + expect(el!.style.getPropertyValue('--dmn-knob-shadow-default')).toBe( + DEFAULT_ELEMENT_ACTIVE_SHADOW, + ); + expect(el!.style.transform).toContain('rotate(90deg)'); + }); + }); + + describe('GraphPanel', () => { + // 앱 빌드는 React Compiler가 history 정규화 결과를 메모하지만 테스트 변환은 + // 컴파일러 없이 실행되어 애니메이션 effect가 매 렌더 재발화 → mount 대신 + // 정적 렌더로 실제 렌더 출력의 DOM 계약만 고정 + it('data 속성과 그래프 변수를 싣는다', () => { + host.innerHTML = renderToStaticMarkup( + , + ); + const el = host.querySelector('[data-graph-element="true"]'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('data-state')).toBe('inactive'); + expect(el!.style.getPropertyValue('--dmn-graph-bg-default')).toBe( + DEFAULT_ELEMENT_BG, + ); + expect(el!.style.getPropertyValue('--dmn-graph-border-default')).toBe( + `1px solid ${DEFAULT_ELEMENT_HAIRLINE}`, + ); + expect(el!.style.getPropertyValue('--dmn-graph-radius-default')).toBe( + '4px', + ); + expect(el!.style.getPropertyValue('--dmn-graph-padding-default')).toBe( + '0px', + ); + }); + }); +}); diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts index d48c112c..c33066a3 100644 --- a/src/renderer/__tests__/setup.ts +++ b/src/renderer/__tests__/setup.ts @@ -1,5 +1,36 @@ // jsdom에 없는 API stub +// Node 26의 실험 localStorage 글로벌(--localstorage-file 미지정 시 undefined)이 +// jsdom 것을 가리므로, 동작하는 구현이 없으면 메모리 shim으로 대체한다 +const hasWorkingLocalStorage = (() => { + try { + return typeof globalThis.localStorage?.getItem === 'function'; + } catch { + return false; + } +})(); +if (!hasWorkingLocalStorage) { + const store = new Map(); + const shim: Storage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, String(value)); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; + Object.defineProperty(globalThis, 'localStorage', { + value: shim, + configurable: true, + }); +} + if (!window.PointerEvent) { class PointerEventPolyfill extends MouseEvent { readonly pointerId: number; diff --git a/src/renderer/__tests__/shadowControls.test.tsx b/src/renderer/__tests__/shadowControls.test.tsx new file mode 100644 index 00000000..2e13e004 --- /dev/null +++ b/src/renderer/__tests__/shadowControls.test.tsx @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ShadowControls from '@components/main/Grid/PropertiesPanel/ShadowControls'; +import type { ElementShadowSpec } from '@src/types/key/shadows'; + +vi.mock('@components/main/Modal/FloatingPopup', () => ({ + default: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, +})); + +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: ({ + color, + onColorChangeComplete, + }: { + color: string; + onColorChangeComplete: (color: string) => void; + }) => ( + + + ) : null} + + + {pickerOpen ? ( + setPickerOpen(false)} + interactiveRefs={[configButtonRef]} + t={t} + /> + ) : null} + + ); +}; + +export default ShadowControls; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx index 3e58e9c5..5e14ea9c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx @@ -11,42 +11,20 @@ import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import CounterAnimationPicker from '@components/main/Modal/content/pickers/CounterAnimationPicker'; +import type { CounterAnimationKeyVisual } from '@utils/core/counterAnimationPreview'; import { usePanelNav } from '../PanelNavContext'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'batch-counter:font'; const ANIMATION_PAGE_KEY = 'batch-counter:animation'; -interface BatchKeyVisual { - width?: number; - height?: number; - backgroundColor?: string; - borderColor?: string; - borderWidth?: number; - borderRadius?: number; - fontColor?: string; - fontSize?: number; - fontWeight?: number; - fontFamily?: string; - fontItalic?: boolean; - fontUnderline?: boolean; - fontStrikethrough?: boolean; - displayText?: string; - displayName?: string; - className?: string; - activeBackgroundColor?: string; - activeBorderColor?: string; - activeFontColor?: string; - useInlineStyles?: boolean; - isStat?: boolean; -} - interface BatchCounterTabContentProps { // 카운터 설정 (첫 번째 선택 키 기준) batchCounterSettings: KeyCounterSettings; // 첫 번째 선택 키의 시각 정보 (프리뷰용) - keyVisual?: BatchKeyVisual; + keyVisual?: CounterAnimationKeyVisual; // 핸들러 handleBatchCounterUpdate: (updates: Partial) => void; // 컬러 디스플레이 (현재 상태 기준) @@ -257,7 +235,7 @@ const BatchCounterTabContent: React.FC = ({ {/* 폰트 크기 */} handleBatchCounterUpdate({ fontSize: value })} suffix="px" min={8} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 97f885b2..1be79fa5 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -10,6 +10,8 @@ import type { GraphItemType, } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { ElementShadowSpec } from '@src/types/key/shadows'; +import type { ColorModeValue } from '@src/types/color'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; import { @@ -99,6 +101,7 @@ interface BatchKeyLikePanelProps { selectedKeyElements: SelectedElement[]; selectedStatElements: SelectedElement[]; selectedGraphElements: SelectedElement[]; + selectedKnobElements: SelectedElement[]; selectedKeyLikeElements: SelectedElement[]; selectedGroupInfo: { id: string; name: string; memberCount: number } | null; isRenaming: boolean; @@ -132,11 +135,27 @@ interface BatchKeyLikePanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchShadowChangeComplete: ( + state: 'idle' | 'active', + patch: Partial, + ) => void; + handleBatchShadowEnabledChange?: (enabled: boolean) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleKeyOnlyStyleChangeComplete: ( property: keyof KeyPosition, value: KeyPosition[keyof KeyPosition], ) => void; - handleBatchCounterUpdate: (updates: Partial) => void; + handleBatchCounterUpdate: ( + updates: Partial, + options?: { + activeStateOnly?: boolean; + colorState?: 'idle' | 'active'; + }, + ) => void; handleBatchNoteColorChange: (value: NoteColor) => void; handleBatchNoteColorChangeComplete: (value: NoteColor) => void; handleBatchGlowColorChange: (value: NoteColor) => void; @@ -148,6 +167,11 @@ interface BatchKeyLikePanelProps { getMixedValueGraphs: MixedValueGetter; getMixedValueGraphsAsKey: MixedValueGetter; getMixedValueKeysOnly: MixedValueGetter; + getMixedValueActiveCapable: MixedValueGetter; + handleActiveCapableStyleChangeComplete: ( + property: keyof KeyPosition, + value: KeyPosition[keyof KeyPosition], + ) => void; getSelectedKeysData: () => KeyData[]; getSelectedGraphsData: () => KeyData[]; getSelectedBatchStyleData: () => KeyData[]; @@ -199,6 +223,7 @@ export const BatchKeyLikePanel: React.FC = ({ selectedBatchStyleElements, selectedKeyElements, selectedStatElements: _selectedStatElements, + selectedKnobElements, selectedGraphElements, selectedKeyLikeElements, selectedGroupInfo, @@ -221,6 +246,9 @@ export const BatchKeyLikePanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchShadowChangeComplete, + handleBatchShadowEnabledChange, + handleBatchGradientCommit, handleKeyOnlyStyleChangeComplete, handleBatchCounterUpdate, handleGraphBatchSharedSetting, @@ -228,10 +256,12 @@ export const BatchKeyLikePanel: React.FC = ({ getMixedValueBatch, getMixedValueGraphs, getMixedValueKeysOnly, + getMixedValueActiveCapable, + handleActiveCapableStyleChangeComplete, getSelectedKeysData, getSelectedGraphsData, getSelectedBatchStyleData, - getSelectedKeyOnlyPositions: _getSelectedKeyOnlyPositions, + getSelectedKeyOnlyPositions, handleBatchKeyOnlyStyleChangeComplete, handleBatchNoteColorChangeKeysOnly: _handleBatchNoteColorChangeKeysOnly, handleBatchNoteColorChangeCompleteKeysOnly: @@ -467,32 +497,17 @@ export const BatchKeyLikePanel: React.FC = ({ }; const keysData = getSelectedKeysData(); - const batchCounterSettings = keysData[0]?.position - ? normalizeCounterSettings(keysData[0].position.counter) + const keyOnlyPositions = getSelectedKeyOnlyPositions(); + const firstCounterPosition = + keyOnlyPositions[0]?.position ?? keysData[0]?.position; + const batchCounterSettings = firstCounterPosition + ? normalizeCounterSettings(firstCounterPosition.counter) : createDefaultCounterSettings(); const firstPos = keysData[0]?.position; const batchKeyVisual = firstPos ? { - width: firstPos.width, - height: firstPos.height, - backgroundColor: firstPos.backgroundColor, - borderColor: firstPos.borderColor, - borderWidth: firstPos.borderWidth, - borderRadius: firstPos.borderRadius, - fontColor: firstPos.fontColor, - fontSize: firstPos.fontSize, - fontWeight: firstPos.fontWeight, - fontFamily: firstPos.fontFamily, - fontItalic: firstPos.fontItalic, - fontUnderline: firstPos.fontUnderline, - fontStrikethrough: firstPos.fontStrikethrough, - displayText: firstPos.displayText, + ...firstPos, displayName: keysData[0]?.keyInfo?.displayName, - className: firstPos.className, - activeBackgroundColor: firstPos.activeBackgroundColor, - activeBorderColor: firstPos.activeBorderColor, - activeFontColor: firstPos.activeFontColor, - useInlineStyles: firstPos.useInlineStyles, isStat: selectedKeyLikeElements[0]?.type === 'stat', } : undefined; @@ -645,6 +660,11 @@ export const BatchKeyLikePanel: React.FC = ({ 0} + showShadowControls={!hasGraphSelection} + shadowActiveState={ + selectedKeyElements.length > 0 || + selectedKnobElements.length > 0 + } getMixedValue={styleMixedValueGetter} getSelectedKeysData={styleSelectedDataGetter} afterSizeContent={ @@ -769,7 +789,16 @@ export const BatchKeyLikePanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchShadowChangeComplete={ + handleBatchShadowChangeComplete + } + handleBatchShadowEnabledChange={handleBatchShadowEnabledChange} + handleBatchGradientCommit={handleBatchGradientCommit} getKeyOnlyMixedValue={getMixedValueKeysOnly} + getActiveCapableMixedValue={getMixedValueActiveCapable} + handleActiveCapableStyleChangeComplete={ + handleActiveCapableStyleChangeComplete + } handleKeyOnlyStyleChangeComplete={ handleKeyOnlyStyleChangeComplete } @@ -864,12 +893,14 @@ export const BatchKeyLikePanel: React.FC = ({ batchPickerFor !== 'noteColor' && batchPickerFor !== 'glowColor' } stateMode={ - batchPickerFor === 'fill' || batchPickerFor === 'stroke' + (batchPickerFor === 'fill' || batchPickerFor === 'stroke') && + selectedKeyElements.length > 0 ? batchCounterColorState : undefined } onStateModeChange={ - batchPickerFor === 'fill' || batchPickerFor === 'stroke' + (batchPickerFor === 'fill' || batchPickerFor === 'stroke') && + selectedKeyElements.length > 0 ? setBatchCounterColorState : undefined } @@ -939,35 +970,42 @@ export const BatchKeyLikePanel: React.FC = ({ : styleMixedValueGetter((pos) => pos.inactiveImage, '').value } activeImage={ - styleMixedValueGetter((pos) => pos.activeImage, '').isMixed + getMixedValueActiveCapable((pos) => pos.activeImage, '').isMixed ? '' - : styleMixedValueGetter((pos) => pos.activeImage, '').value + : getMixedValueActiveCapable((pos) => pos.activeImage, '').value } idleTransparent={ styleMixedValueGetter((pos) => pos.idleTransparent, false).value } activeTransparent={ - styleMixedValueGetter((pos) => pos.activeTransparent, false).value + getMixedValueActiveCapable((pos) => pos.activeTransparent, false) + .value } onIdleImageChange={(imageUrl: string) => { handleBatchStyleChangeComplete('inactiveImage', imageUrl); }} onActiveImageChange={(imageUrl: string) => { - handleBatchStyleChangeComplete('activeImage', imageUrl); + handleActiveCapableStyleChangeComplete('activeImage', imageUrl); }} onIdleTransparentChange={(value: boolean) => { handleBatchStyleChangeComplete('idleTransparent', value); }} onActiveTransparentChange={(value: boolean) => { - handleBatchStyleChangeComplete('activeTransparent', value); + handleActiveCapableStyleChangeComplete( + 'activeTransparent', + value, + ); }} onIdleImageReset={() => { handleBatchStyleChangeComplete('inactiveImage', ''); }} onActiveImageReset={() => { - handleBatchStyleChangeComplete('activeImage', ''); + handleActiveCapableStyleChangeComplete('activeImage', ''); }} onClose={() => setShowBatchImagePicker(false)} + showActiveState={ + selectedKeyElements.length > 0 || selectedKnobElements.length > 0 + } /> )} @@ -1011,6 +1049,11 @@ interface BatchGraphOnlyPanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleGraphBatchSharedSetting: (updates: Partial) => void; getMixedValueGraphs: MixedValueGetter; getMixedValueGraphsAsKey: MixedValueGetter; @@ -1046,6 +1089,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, handleGraphBatchSharedSetting, getMixedValueGraphs, getMixedValueGraphsAsKey, @@ -1162,6 +1206,8 @@ export const BatchGraphOnlyPanel: React.FC = ({ hideDisplayText hideFontControls showSoundControls={false} + showShadowControls={false} + shadowActiveState={false} afterSizeContent={ <> = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} showBatchImagePicker={showBatchImagePicker} onToggleBatchImagePicker={() => setShowBatchImagePicker(!showBatchImagePicker) @@ -1295,6 +1342,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ open={showBatchImagePicker} referenceRef={batchImageButtonRef} panelElement={panelElement} + showActiveState={false} idleImage={ getMixedValueGraphs((pos) => pos.inactiveImage, '').isMixed ? '' @@ -1372,6 +1420,16 @@ interface BatchKnobOnlyPanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchShadowChangeComplete: ( + state: 'idle' | 'active', + patch: Partial, + ) => void; + handleBatchShadowEnabledChange?: (enabled: boolean) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleKnobBatchSharedSetting: (updates: Partial) => void; getMixedValueKnobs: MixedValueGetter; getMixedValueKnobsAsKey: MixedValueGetter; @@ -1407,6 +1465,9 @@ export const BatchKnobOnlyPanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchShadowChangeComplete, + handleBatchShadowEnabledChange, + handleBatchGradientCommit, handleKnobBatchSharedSetting, getMixedValueKnobs, getMixedValueKnobsAsKey, @@ -1498,6 +1559,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ hideDisplayText hideFontControls showSoundControls={false} + shadowKind="knob" afterSizeContent={ <> = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchShadowChangeComplete={handleBatchShadowChangeComplete} + handleBatchShadowEnabledChange={handleBatchShadowEnabledChange} + handleBatchGradientCommit={handleBatchGradientCommit} showBatchImagePicker={showBatchImagePicker} onToggleBatchImagePicker={() => setShowBatchImagePicker(!showBatchImagePicker) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index eb0d78ce..882f3efa 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -1,6 +1,8 @@ -import React, { useState, useRef, useEffect } from 'react'; +/* eslint-disable react-hooks/set-state-in-effect */ +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import type { KeyPosition } from '@src/types/key/keys'; +import { resolveStatePair, type ColorModeValue } from '@src/types/color'; import { PropertyRow, NumberInput, @@ -10,9 +12,29 @@ import { FontStyleToggle, } from '../index'; import Checkbox from '@components/main/common/Checkbox'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import SoundPicker from '@components/main/Modal/content/pickers/SoundPicker'; import { usePanelNav } from '../PanelNavContext'; +import ShadowControls from '../ShadowControls'; +import { + resolveElementShadow, + type ElementShadowSpec, +} from '@src/types/key/shadows'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'batch-style:font'; @@ -34,6 +56,10 @@ interface BatchStyleTabContentProps { hideDisplayText?: boolean; hideFontControls?: boolean; showSoundControls?: boolean; + showShadowControls?: boolean; + // 선택에 키·노브가 없으면(통계뿐) 그림자 대기만 편집 + shadowActiveState?: boolean; + shadowKind?: 'key' | 'knob'; afterSizeContent?: React.ReactNode; // getMixedValue 함수 getMixedValue: ( @@ -63,6 +89,16 @@ interface BatchStyleTabContentProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchShadowChangeComplete?: ( + state: 'idle' | 'active', + patch: Partial, + ) => void; + handleBatchShadowEnabledChange?: (enabled: boolean) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; // 키 전용 (사운드 등) getKeyOnlyMixedValue?: ( getter: (pos: KeyPosition) => T | undefined, @@ -72,6 +108,15 @@ interface BatchStyleTabContentProps { property: keyof KeyPosition, value: unknown, ) => void; + // 눌림 가능(키·노브) — active 상태 집계·쓰기가 통계만 제외 + getActiveCapableMixedValue?: ( + getter: (pos: KeyPosition) => T | undefined, + defaultValue: T, + ) => { isMixed: boolean; value: T }; + handleActiveCapableStyleChangeComplete?: ( + property: keyof KeyPosition, + value: unknown, + ) => void; // 이미지 피커 showBatchImagePicker: boolean; onToggleBatchImagePicker: () => void; @@ -87,6 +132,9 @@ const BatchStyleTabContent: React.FC = ({ hideDisplayText = false, hideFontControls = false, showSoundControls = false, + showShadowControls = true, + shadowActiveState = true, + shadowKind = 'key', afterSizeContent, getMixedValue, getSelectedKeysData, @@ -98,8 +146,13 @@ const BatchStyleTabContent: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchShadowChangeComplete, + handleBatchShadowEnabledChange, + handleBatchGradientCommit, getKeyOnlyMixedValue, handleKeyOnlyStyleChangeComplete, + getActiveCapableMixedValue, + handleActiveCapableStyleChangeComplete, showBatchImagePicker, onToggleBatchImagePicker, batchImageButtonRef, @@ -108,10 +161,154 @@ const BatchStyleTabContent: React.FC = ({ t, }) => { const [colorState, setColorState] = useState<'idle' | 'active'>('idle'); + const effectiveColorState = shadowActiveState ? colorState : 'idle'; + const activeMixedValue = + getActiveCapableMixedValue ?? getKeyOnlyMixedValue ?? getMixedValue; + const handleActiveStyleChangeComplete = + handleActiveCapableStyleChangeComplete ?? + handleKeyOnlyStyleChangeComplete ?? + handleBatchStyleChangeComplete; + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); + const selectedElements = useGridSelectionStore( + (state) => state.selectedElements, + ); + // 선택 구성 시그니처 — 형식 왕복 기억·드래그 소유권이 다른 배치 선택과 + // 교차하지 않게 keyType + 정렬된 대상 목록을 키에 포함 + const batchSelectionKey = useMemo( + () => + `${selectedKeyType}:${selectedElements + .map((el) => el.id) + .sort() + .join(',')}`, + [selectedKeyType, selectedElements], + ); // 인-패널 내비게이션 (폰트/사운드 서브 페이지) const { activePageKey, renderPageKey, openPage, closePage, pageHost } = usePanelNav(); + useEffect(() => { + if (!shadowActiveState) setColorState('idle'); + }, [shadowActiveState]); + + const colorPairFor = ( + position: KeyPosition, + target: 'backgroundColor' | 'borderColor', + active: boolean, + ) => { + if (target === 'backgroundColor') { + return resolveStatePair( + active, + { + color: position.backgroundColor, + gradient: position.backgroundGradient, + }, + { + color: position.activeBackgroundColor, + gradient: position.activeBackgroundGradient, + }, + ); + } + return resolveStatePair( + active, + { color: position.borderColor, gradient: position.borderGradient }, + { + color: position.activeBorderColor, + gradient: position.activeBorderGradient, + }, + ); + }; + + const fontColorFor = (position: KeyPosition, active: boolean) => { + const idle = position.fontColor?.trim() ? position.fontColor : undefined; + const activeColor = position.activeFontColor?.trim() + ? position.activeFontColor + : undefined; + return active ? activeColor ?? idle : idle; + }; + + const resolvedShadowFor = (position: KeyPosition, active: boolean) => { + const hasImage = Boolean( + active + ? position.activeImage?.trim() || position.inactiveImage?.trim() + : position.inactiveImage?.trim(), + ); + const suppressDefault = + hasImage || + (shadowKind === 'knob' && + ((active + ? position.activeTransparent === true + : position.idleTransparent === true) || + (position.borderWidth ?? 0) > 0)); + return resolveElementShadow({ + active, + shadow: position.shadow, + activeShadow: position.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault, + }); + }; + + const getBatchShadow = (active: boolean) => { + const fallback = active + ? DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC + : DEFAULT_ELEMENT_SHADOW_SPEC; + const mixedValue = active ? activeMixedValue : getMixedValue; + const enabled = mixedValue( + (position) => resolvedShadowFor(position, active).enabled, + fallback.enabled, + ); + const color = mixedValue( + (position) => resolvedShadowFor(position, active).color, + fallback.color, + ); + const offsetX = mixedValue( + (position) => resolvedShadowFor(position, active).offsetX, + fallback.offsetX, + ); + const offsetY = mixedValue( + (position) => resolvedShadowFor(position, active).offsetY, + fallback.offsetY, + ); + const blur = mixedValue( + (position) => resolvedShadowFor(position, active).blur, + fallback.blur, + ); + + return { + value: { + enabled: enabled.value, + color: color.value, + offsetX: offsetX.value, + offsetY: offsetY.value, + blur: blur.value, + }, + // 대표값은 첫 요소 기준 — 토글 표시용 "하나라도 켜짐"은 별도 계산 + enabledAny: enabled.value || enabled.isMixed, + isMixed: + enabled.isMixed || + color.isMixed || + offsetX.isMixed || + offsetY.isMixed || + blur.isMixed, + }; + }; + + const batchIdleShadow = getBatchShadow(false); + const batchActiveShadow = getBatchShadow(true); + + const handleShadowChange = ( + state: 'idle' | 'active', + _shadow: ElementShadowSpec, + patch: Partial, + ) => { + handleBatchShadowChangeComplete?.(state, patch); + }; + + const handleShadowEnabledChange = (enabled: boolean) => { + handleBatchShadowEnabledChange?.(enabled); + }; + // 간격 입력 세션 동안 첫 변경만 히스토리를 남기고 이후는 skipHistory로 묶는다. const lastSpacingRef = useRef(null); const lastCommittedSpacingRef = useRef(null); @@ -534,33 +731,34 @@ const BatchStyleTabContent: React.FC = ({ {/* 배경색 */} {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', + effectiveColorState === 'active' + ? activeMixedValue( + (pos) => colorPairFor(pos, 'backgroundColor', true).color, + DEFAULT_ELEMENT_ACTIVE_BG, ).isMixed : getMixedValue( - (pos) => pos.backgroundColor, - 'rgba(46, 46, 47, 0.9)', + (pos) => colorPairFor(pos, 'backgroundColor', false).color, + DEFAULT_ELEMENT_BG, ).isMixed ) ? ( Mixed ) : null} pos.backgroundColor, - 'rgba(46, 46, 47, 0.9)', + (pos) => colorPairFor(pos, 'backgroundColor', false).color, + DEFAULT_ELEMENT_BG, ).value } activeValue={ - getMixedValue( - (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', + activeMixedValue( + (pos) => colorPairFor(pos, 'backgroundColor', true).color, + DEFAULT_ELEMENT_ACTIVE_BG, ).value } - showStateTabs - stateMode={colorState} + showStateTabs={shadowActiveState} + stateMode={effectiveColorState} onStateModeChange={setColorState} onChange={(color) => handleBatchStyleChange('backgroundColor', color) @@ -568,68 +766,118 @@ const BatchStyleTabContent: React.FC = ({ onChangeComplete={(color) => handleBatchStyleChangeComplete('backgroundColor', color) } - onActiveChange={(color) => - handleBatchStyleChange('activeBackgroundColor', color) - } onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeBackgroundColor', color) + handleActiveStyleChangeComplete('activeBackgroundColor', color) } panelElement={panelElement} + canvasAnchor={{ kind: 'batch' }} + gradientValue={ + getMixedValue( + (pos) => + colorPairFor(pos, 'backgroundColor', false).gradient ?? null, + null, + ).value + } + activeGradientValue={ + activeMixedValue( + (pos) => + colorPairFor(pos, 'backgroundColor', true).gradient ?? null, + null, + ).value + } + onModeCommit={ + handleBatchGradientCommit + ? (state, modeValue) => + handleBatchGradientCommit( + 'backgroundColor', + state, + modeValue, + ) + : undefined + } /> {/* 테두리 색상 */} {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', + effectiveColorState === 'active' + ? activeMixedValue( + (pos) => colorPairFor(pos, 'borderColor', true).color, + DEFAULT_ELEMENT_ACTIVE_BORDER, ).isMixed : getMixedValue( - (pos) => pos.borderColor, - 'rgba(113, 113, 113, 0.9)', + (pos) => colorPairFor(pos, 'borderColor', false).color, + DEFAULT_ELEMENT_BORDER, ).isMixed ) ? ( Mixed ) : null} pos.borderColor, - 'rgba(113, 113, 113, 0.9)', + (pos) => colorPairFor(pos, 'borderColor', false).color, + DEFAULT_ELEMENT_BORDER, ).value } activeValue={ - getMixedValue( - (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', + activeMixedValue( + (pos) => colorPairFor(pos, 'borderColor', true).color, + DEFAULT_ELEMENT_ACTIVE_BORDER, ).value } - showStateTabs - stateMode={colorState} + showStateTabs={shadowActiveState} + stateMode={effectiveColorState} onStateModeChange={setColorState} onChange={(color) => handleBatchStyleChange('borderColor', color)} onChangeComplete={(color) => handleBatchStyleChangeComplete('borderColor', color) } - onActiveChange={(color) => - handleBatchStyleChange('activeBorderColor', color) - } onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeBorderColor', color) + handleActiveStyleChangeComplete('activeBorderColor', color) } panelElement={panelElement} + canvasAnchor={{ kind: 'batch' }} + gradientValue={ + getMixedValue( + (pos) => + colorPairFor(pos, 'borderColor', false).gradient ?? null, + null, + ).value + } + activeGradientValue={ + activeMixedValue( + (pos) => + colorPairFor(pos, 'borderColor', true).gradient ?? null, + null, + ).value + } + onModeCommit={ + handleBatchGradientCommit + ? (state, modeValue) => + handleBatchGradientCommit('borderColor', state, modeValue) + : undefined + } /> {/* 테두리 두께 */} - {getMixedValue((pos) => pos.borderWidth, 3).isMixed ? ( + {getMixedValue( + (pos) => pos.borderWidth ?? DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_BORDER_WIDTH, + ).isMixed ? ( Mixed ) : null} pos.borderWidth, 3).value} + value={ + getMixedValue( + (pos) => pos.borderWidth ?? DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_BORDER_WIDTH, + ).value + } onChange={(value) => handleBatchStyleChangeComplete('borderWidth', value) } @@ -643,11 +891,15 @@ const BatchStyleTabContent: React.FC = ({ {/* 모서리 반경 */} - {getMixedValue((pos) => pos.borderRadius, 10).isMixed ? ( + {getMixedValue((pos) => pos.borderRadius, DEFAULT_ELEMENT_RADIUS) + .isMixed ? ( Mixed ) : null} pos.borderRadius, 10).value} + value={ + getMixedValue((pos) => pos.borderRadius, DEFAULT_ELEMENT_RADIUS) + .value + } onChange={(value) => handleBatchStyleChangeComplete('borderRadius', value) } @@ -676,6 +928,24 @@ const BatchStyleTabContent: React.FC = ({ + {showShadowControls ? ( + + ) : null} + {(!hideDisplayText || !hideFontControls) && ( {/* 표시 텍스트 */} @@ -749,14 +1019,14 @@ const BatchStyleTabContent: React.FC = ({ label={t('propertiesPanel.fontColor') || '글꼴 색상'} > {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', + effectiveColorState === 'active' + ? activeMixedValue( + (pos) => fontColorFor(pos, true), + DEFAULT_ELEMENT_ACTIVE_FONT, ).isMixed : getMixedValue( - (pos) => pos.fontColor, - 'rgba(121, 121, 121, 0.9)', + (pos) => fontColorFor(pos, false), + DEFAULT_ELEMENT_FONT, ).isMixed ) ? ( Mixed @@ -764,18 +1034,18 @@ const BatchStyleTabContent: React.FC = ({ pos.fontColor, - 'rgba(121, 121, 121, 0.9)', + (pos) => fontColorFor(pos, false), + DEFAULT_ELEMENT_FONT, ).value } activeValue={ - getMixedValue( - (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', + activeMixedValue( + (pos) => fontColorFor(pos, true), + DEFAULT_ELEMENT_ACTIVE_FONT, ).value } - showStateTabs - stateMode={colorState} + showStateTabs={shadowActiveState} + stateMode={effectiveColorState} onStateModeChange={setColorState} onChange={(color) => handleBatchStyleChange('fontColor', color) @@ -783,11 +1053,8 @@ const BatchStyleTabContent: React.FC = ({ onChangeComplete={(color) => handleBatchStyleChangeComplete('fontColor', color) } - onActiveChange={(color) => - handleBatchStyleChange('activeFontColor', color) - } onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeFontColor', color) + handleActiveStyleChangeComplete('activeFontColor', color) } panelElement={panelElement} /> @@ -799,8 +1066,11 @@ const BatchStyleTabContent: React.FC = ({ > (pos.fontWeight ?? 700) >= 700, true) - .value + getMixedValue( + (pos) => + (pos.fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT) >= 700, + true, + ).value } isItalic={getMixedValue((pos) => pos.fontItalic, false).value} isUnderline={ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts index 6136cdac..2034d987 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts @@ -4,20 +4,30 @@ import type { KeyCounterSettings, } from '@src/types/key/keys'; import { normalizeCounterSettings } from '@src/types/key/keys'; +import { + getActivePairPreservation, + gradientPairPatch, + type ColorModeValue, +} from '@src/types/color'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import { + resolveElementShadow, + type ElementShadowSpec, +} from '@src/types/key/shadows'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; import type { EditorPatchV1 } from '@src/types/editor'; -const DEFAULT_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; -const DEFAULT_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; -const DEFAULT_ACTIVE_FONT_COLOR = '#FFFFFF'; const SPACING_GROUP_TOLERANCE = 2; const SPACING_DECIMAL_SCALE = 1; const POSITION_CHANGE_EPSILON = 0.05; @@ -29,6 +39,93 @@ const PRIMARY_AXIS_STACK_EPSILON = 0.1; type KeyLikeType = 'key' | 'stat' | 'graph' | 'knob'; type AxisDirection = 'horizontal' | 'vertical'; +type IdleColorProperty = 'backgroundColor' | 'borderColor' | 'fontColor'; +type ActiveColorProperty = + | 'activeBackgroundColor' + | 'activeBorderColor' + | 'activeFontColor'; + +const ACTIVE_COLOR_PROPERTY: Record = { + backgroundColor: 'activeBackgroundColor', + borderColor: 'activeBorderColor', + fontColor: 'activeFontColor', +}; + +const GRADIENT_PROPERTY = { + backgroundColor: { + idle: 'backgroundGradient', + active: 'activeBackgroundGradient', + }, + borderColor: { + idle: 'borderGradient', + active: 'activeBorderGradient', + }, +} as const; + +const isIdleColorProperty = ( + property: keyof KeyPosition, +): property is IdleColorProperty => property in ACTIVE_COLOR_PROPERTY; + +const ACTIVE_STATE_PROPERTIES = new Set([ + 'activeBackgroundColor', + 'activeBorderColor', + 'activeFontColor', + 'activeBackgroundGradient', + 'activeBorderGradient', + 'activeImage', + 'activeTransparent', + 'activeImageFit', + 'activeShadow', +]); + +const isActiveStateProperty = (property: keyof KeyPosition): boolean => + ACTIVE_STATE_PROPERTIES.has(property); + +const buildBatchStyleUpdate = ( + index: number, + position: KeyPosition | undefined, + property: keyof KeyPosition, + value: KeyPosition[keyof KeyPosition], + includeFontColor = true, + preserveActiveState = true, +): { index: number } & Partial => { + const update = { index, [property]: value } as { + index: number; + } & Partial; + if ( + !position || + !preserveActiveState || + !isIdleColorProperty(property) || + (!includeFontColor && property === 'fontColor') + ) { + return update; + } + + const activeProperty = ACTIVE_COLOR_PROPERTY[property]; + const gradientProperty = + property === 'fontColor' ? undefined : GRADIENT_PROPERTY[property]; + const preservation = getActivePairPreservation( + { + color: position[property], + gradient: gradientProperty ? position[gradientProperty.idle] : undefined, + }, + { + color: position[activeProperty], + gradient: gradientProperty + ? position[gradientProperty.active] + : undefined, + }, + ); + if (preservation?.color !== undefined) { + Object.assign(update, { [activeProperty]: preservation.color }); + } + if (gradientProperty && preservation?.gradient !== undefined) { + Object.assign(update, { + [gradientProperty.active]: preservation.gradient, + }); + } + return update; +}; interface LayoutElement { type: KeyLikeType; @@ -787,18 +884,22 @@ export function useBatchHandlers({ >; dispatchKeyUpdates(keyUpdates, 'preview'); - const statUpdates = selectedStats - .filter((el) => el.index !== undefined) - .map((el) => ({ index: el.index!, [property]: value })) as Array< - { index: number } & Partial - >; + const statUpdates = isActiveStateProperty(property) + ? [] + : (selectedStats + .filter((el) => el.index !== undefined) + .map((el) => ({ index: el.index!, [property]: value })) as Array< + { index: number } & Partial + >); dispatchStatUpdates(statUpdates, 'preview'); - const graphUpdates = selectedGraphs - .filter((el) => el.index !== undefined) - .map((el) => ({ index: el.index!, [property]: value })) as Array< - { index: number } & Partial - >; + const graphUpdates = isActiveStateProperty(property) + ? [] + : (selectedGraphs + .filter((el) => el.index !== undefined) + .map((el) => ({ index: el.index!, [property]: value })) as Array< + { index: number } & Partial + >); dispatchGraphUpdates(graphUpdates, 'preview'); const knobUpdates = selectedKnobs @@ -821,130 +922,47 @@ export function useBatchHandlers({ .filter((el) => el.index !== undefined) .map((el) => { const index = el.index!; - const pos = currentKeys[index]; - if (pos) { - if ( - property === 'backgroundColor' && - pos.activeBackgroundColor == null - ) { - return { - index, - backgroundColor: value, - activeBackgroundColor: - pos.activeBackgroundColor ?? - pos.backgroundColor ?? - DEFAULT_ACTIVE_BACKGROUND_COLOR, - }; - } - if (property === 'borderColor' && pos.activeBorderColor == null) { - return { - index, - borderColor: value, - activeBorderColor: - pos.activeBorderColor ?? - pos.borderColor ?? - DEFAULT_ACTIVE_BORDER_COLOR, - }; - } - if (property === 'fontColor' && pos.activeFontColor == null) { - return { - index, - fontColor: value, - activeFontColor: - pos.activeFontColor ?? - pos.fontColor ?? - DEFAULT_ACTIVE_FONT_COLOR, - }; - } - } - return { index, [property]: value } as { - index: number; - } & Partial; + return buildBatchStyleUpdate( + index, + currentKeys[index], + property, + value, + ); }); - const statUpdates = selectedStats - .filter((el) => el.index !== undefined) - .map((el) => { - const index = el.index!; - const pos = currentStats[index]; - if (pos) { - if ( - property === 'backgroundColor' && - pos.activeBackgroundColor == null - ) { - return { - index, - backgroundColor: value, - activeBackgroundColor: - pos.activeBackgroundColor ?? - pos.backgroundColor ?? - DEFAULT_ACTIVE_BACKGROUND_COLOR, - } as { index: number } & Partial; - } - if (property === 'borderColor' && pos.activeBorderColor == null) { - return { + const statUpdates = isActiveStateProperty(property) + ? [] + : selectedStats + .filter((el) => el.index !== undefined) + .map((el) => { + const index = el.index!; + return buildBatchStyleUpdate( index, - borderColor: value, - activeBorderColor: - pos.activeBorderColor ?? - pos.borderColor ?? - DEFAULT_ACTIVE_BORDER_COLOR, - } as { index: number } & Partial; - } - if (property === 'fontColor' && pos.activeFontColor == null) { - return { - index, - fontColor: value, - activeFontColor: - pos.activeFontColor ?? - pos.fontColor ?? - DEFAULT_ACTIVE_FONT_COLOR, - } as { index: number } & Partial; - } - } - return { index, [property]: value } as { - index: number; - } & Partial; - }); - const graphUpdates = selectedGraphs - .filter((el) => el.index !== undefined) - .map((el) => ({ index: el.index!, [property]: value })) as Array< - { index: number } & Partial - >; + currentStats[index], + property, + value, + true, + false, + ) as { index: number } & Partial; + }); + const graphUpdates = isActiveStateProperty(property) + ? [] + : (selectedGraphs + .filter((el) => el.index !== undefined) + .map((el) => ({ index: el.index!, [property]: value })) as Array< + { index: number } & Partial + >); const currentKnobs = knobPositions?.[selectedKeyType] || []; const knobUpdates = selectedKnobs .filter((el) => el.index !== undefined) .map((el) => { const index = el.index!; - const pos = currentKnobs[index]; - // idle 변경 시 active가 비어 있으면 현재 표시값을 함께 저장 (키/통계와 동일) - if (pos) { - if ( - property === 'backgroundColor' && - pos.activeBackgroundColor == null - ) { - return { - index, - backgroundColor: value, - activeBackgroundColor: - pos.activeBackgroundColor ?? - pos.backgroundColor ?? - DEFAULT_ACTIVE_BACKGROUND_COLOR, - } as { index: number } & Partial; - } - if (property === 'borderColor' && pos.activeBorderColor == null) { - return { - index, - borderColor: value, - activeBorderColor: - pos.activeBorderColor ?? - pos.borderColor ?? - DEFAULT_ACTIVE_BORDER_COLOR, - } as { index: number } & Partial; - } - } - return { index, [property]: value } as { - index: number; - } & Partial; + return buildBatchStyleUpdate( + index, + currentKnobs[index], + property, + value, + false, + ) as { index: number } & Partial; }); dispatchKeyLikeUpdates([ ...keyUpdates.map((update) => ({ type: 'key' as const, ...update })), @@ -963,6 +981,213 @@ export function useBatchHandlers({ ] as KeyLikeBatchUpdate[]); }; + // 요소별 저장값+기본값을 합친 실제 그림자 (이미지·노브 투명 등 기본 억제 규칙 포함) + const resolveShadowFor = ( + position: KeyPosition, + active: boolean, + kind: 'key' | 'knob', + ): ElementShadowSpec => { + const hasImage = Boolean( + active + ? position.activeImage?.trim() || position.inactiveImage?.trim() + : position.inactiveImage?.trim(), + ); + const suppressDefault = + hasImage || + (kind === 'knob' && + ((active + ? position.activeTransparent === true + : position.idleTransparent === true) || + (position.borderWidth ?? 0) > 0)); + return resolveElementShadow({ + active, + shadow: position.shadow, + activeShadow: position.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault, + }); + }; + + const dispatchShadowUpdates = ( + buildUpdate: ( + index: number, + position: KeyPosition | undefined, + kind: 'key' | 'knob', + elementType: 'key' | 'stat' | 'knob', + ) => { index: number } & Partial, + ) => { + const currentKeys = keyPositions[selectedKeyType] || []; + const currentStats = statPositions[selectedKeyType] || []; + const currentKnobs = knobPositions?.[selectedKeyType] || []; + + dispatchKeyLikeUpdates([ + ...selectedKeys + .filter((element) => element.index !== undefined) + .map((element) => ({ + type: 'key' as const, + ...buildUpdate( + element.index!, + currentKeys[element.index!], + 'key', + 'key', + ), + })), + ...selectedStats + .filter((element) => element.index !== undefined) + .map((element) => ({ + type: 'stat' as const, + ...buildUpdate( + element.index!, + currentStats[element.index!], + 'key', + 'stat', + ), + })), + ...selectedKnobs + .filter((element) => element.index !== undefined) + .map((element) => ({ + type: 'knob' as const, + ...buildUpdate( + element.index!, + currentKnobs[element.index!], + 'knob', + 'knob', + ), + })), + ] as KeyLikeBatchUpdate[]); + }; + + const handleBatchShadowChangeComplete = ( + state: 'idle' | 'active', + patch: Partial, + ) => { + const active = state === 'active'; + const field = active ? 'activeShadow' : 'shadow'; + dispatchShadowUpdates((index, position, kind, elementType) => { + if (!position) return { index }; + // 통계는 눌림 상태가 없음 — 입력 그림자를 기록하지 않음 + if (active && elementType === 'stat') return { index }; + return { + index, + [field]: { ...resolveShadowFor(position, active, kind), ...patch }, + }; + }); + }; + + // 마스터 토글 — 대기·입력 그림자를 요소별 현재 값 기준으로 한 번에 켜고 끔 + const handleBatchShadowEnabledChange = (enabled: boolean) => { + dispatchShadowUpdates((index, position, kind, elementType) => { + if (!position) return { index }; + return { + index, + shadow: { ...resolveShadowFor(position, false, kind), enabled }, + // 통계는 눌림 상태가 없음 — activeShadow 실체화 금지 + ...(elementType === 'stat' + ? {} + : { + activeShadow: { + ...resolveShadowFor(position, true, kind), + enabled, + }, + }), + }; + }); + }; + + // 그라데이션 커밋 — 배경/테두리 쌍(base+sibling)을 선택 요소 전체에 atomic 적용 + const handleBatchGradientCommit = ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => { + const isBg = target === 'backgroundColor'; + const baseField = + state === 'active' + ? isBg + ? 'activeBackgroundColor' + : 'activeBorderColor' + : target; + const pairPatch = gradientPairPatch( + baseField, + value, + ) as Partial; + + const buildUpdate = ( + index: number, + pos: KeyPosition | undefined, + preserveActiveState = true, + ): { index: number } & Partial => { + const update: { index: number } & Partial = { + index, + ...pairPatch, + }; + // idle 편집 전 사용자 저장값 기준 active 쌍 보존 + if (state === 'idle' && pos && preserveActiveState) { + const preservation = getActivePairPreservation( + { + color: isBg ? pos.backgroundColor : pos.borderColor, + gradient: isBg ? pos.backgroundGradient : pos.borderGradient, + }, + { + color: isBg ? pos.activeBackgroundColor : pos.activeBorderColor, + gradient: isBg + ? pos.activeBackgroundGradient + : pos.activeBorderGradient, + }, + ); + if (preservation?.color !== undefined) { + if (isBg) { + update.activeBackgroundColor = preservation.color; + } else { + update.activeBorderColor = preservation.color; + } + } + if (preservation?.gradient !== undefined) { + if (isBg) { + update.activeBackgroundGradient = preservation.gradient; + } else { + update.activeBorderGradient = preservation.gradient; + } + } + } + return update; + }; + + const currentKeys = keyPositions[selectedKeyType] || []; + const currentStats = statPositions[selectedKeyType] || []; + const currentGraphs = graphPositions?.[selectedKeyType] || []; + const currentKnobs = knobPositions?.[selectedKeyType] || []; + + dispatchKeyLikeUpdates([ + ...selectedKeys + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'key' as const, + ...buildUpdate(el.index!, currentKeys[el.index!]), + })), + ...selectedStats + .filter((el) => state !== 'active' && el.index !== undefined) + .map((el) => ({ + type: 'stat' as const, + ...buildUpdate(el.index!, currentStats[el.index!], false), + })), + ...selectedGraphs + // 그래프는 active 상태가 없음 — 입력 그라데이션 기록 제외 + .filter((el) => state !== 'active' && el.index !== undefined) + .map((el) => ({ + type: 'graph' as const, + ...buildUpdate(el.index!, currentGraphs[el.index!]), + })), + ...selectedKnobs + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'knob' as const, + ...buildUpdate(el.index!, currentKnobs[el.index!]), + })), + ] as KeyLikeBatchUpdate[]); + }; + // 정렬 핸들러 const handleBatchAlign = ( direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', @@ -1246,27 +1471,52 @@ export function useBatchHandlers({ }; // 카운터 업데이트 핸들러 - const handleBatchCounterUpdate = (updates: Partial) => { + const handleBatchCounterUpdate = ( + updates: Partial, + options?: { + activeStateOnly?: boolean; + colorState?: 'idle' | 'active'; + }, + ) => { + const mergeCounterSettings = ( + currentSettings: KeyCounterSettings, + ): KeyCounterSettings => { + const newSettings = { ...currentSettings, ...updates }; + if (options?.colorState && updates.fill) { + newSettings.fill = { + ...currentSettings.fill, + [options.colorState]: updates.fill[options.colorState], + }; + } + if (options?.colorState && updates.stroke) { + newSettings.stroke = { + ...currentSettings.stroke, + [options.colorState]: updates.stroke[options.colorState], + }; + } + return newSettings; + }; + const keyUpdates = selectedKeys .filter((el) => el.index !== undefined) .map((el) => { const pos = keyPositions[selectedKeyType]?.[el.index!]; if (!pos) return null; const currentSettings = normalizeCounterSettings(pos.counter); - const newSettings = { ...currentSettings, ...updates }; + const newSettings = mergeCounterSettings(currentSettings); return { index: el.index!, counter: newSettings }; }) .filter( (update): update is { index: number; counter: KeyCounterSettings } => update !== null, ); - const statUpdates = selectedStats + const statUpdates = (options?.activeStateOnly ? [] : selectedStats) .filter((el) => el.index !== undefined) .map((el) => { const pos = statPositions[selectedKeyType]?.[el.index!]; if (!pos) return null; const currentSettings = normalizeCounterSettings(pos.counter); - const newSettings = { ...currentSettings, ...updates }; + const newSettings = mergeCounterSettings(currentSettings); return { index: el.index!, counter: newSettings } as { index: number; } & Partial; @@ -1408,10 +1658,37 @@ export function useBatchHandlers({ dispatchKeyUpdates(keyUpdates, 'commit'); }; + // 눌림 가능(키·노브) 전용 — active 상태 쓰기가 통계만 제외하고 노브는 포함 + const handleActiveCapableStyleChangeComplete = ( + property: keyof KeyPosition, + value: KeyPosition[keyof KeyPosition], + ) => { + dispatchKeyLikeUpdates([ + ...selectedKeys + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'key' as const, + index: el.index!, + [property]: value, + })), + ...selectedKnobs + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'knob' as const, + index: el.index!, + [property]: value, + })), + ] as KeyLikeBatchUpdate[]); + }; + return { handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchShadowChangeComplete, + handleBatchShadowEnabledChange, + handleBatchGradientCommit, handleKeyOnlyStyleChangeComplete, + handleActiveCapableStyleChangeComplete, handleBatchAlign, handleBatchDistribute, handleBatchSpacing, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index 4a581fe4..ba83d62e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -20,6 +20,14 @@ import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import CounterAnimationPicker from '@components/main/Modal/content/pickers/CounterAnimationPicker'; import { usePanelNav } from '../PanelNavContext'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { + counterFillPair, + gradientToCss, + type ColorModeValue, +} from '@src/types/color'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'single-counter:font'; @@ -43,6 +51,16 @@ const CounterTabContent: React.FC = ({ const [pickerFor, setPickerFor] = useState(null); const pickerOpen = pickerFor !== null; const [colorState, setColorState] = useState('idle'); + const showActiveState = !isStat; + const effectiveColorState = showActiveState ? colorState : 'idle'; + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); + + useEffect(() => { + if (!showActiveState) { + setColorState('idle'); + setPickerFor(null); + } + }, [showActiveState]); // 인-패널 내비게이션 (폰트/애니메이션 서브 페이지) const { activePageKey, renderPageKey, openPage, closePage, pageHost } = @@ -118,10 +136,10 @@ const CounterTabContent: React.FC = ({ if (!pickerFor) return; const key = pickerFor === 'fill' - ? colorState === 'active' + ? effectiveColorState === 'active' ? 'fillActive' : 'fillIdle' - : colorState === 'active' + : effectiveColorState === 'active' ? 'strokeActive' : 'strokeIdle'; @@ -134,17 +152,17 @@ const CounterTabContent: React.FC = ({ const key = pickerFor === 'fill' - ? colorState === 'active' + ? effectiveColorState === 'active' ? 'fillActive' : 'fillIdle' - : colorState === 'active' + : effectiveColorState === 'active' ? 'strokeActive' : 'strokeIdle'; setLocalColors((prev) => ({ ...prev, [key]: color })); if (pickerFor === 'fill') { - if (colorState === 'active') { + if (effectiveColorState === 'active') { handleCounterUpdate({ fill: { ...counterSettings.fill, active: color }, }); @@ -156,7 +174,7 @@ const CounterTabContent: React.FC = ({ return; } - if (colorState === 'active') { + if (effectiveColorState === 'active') { handleCounterUpdate({ stroke: { ...counterSettings.stroke, active: color }, }); @@ -167,6 +185,55 @@ const CounterTabContent: React.FC = ({ } }; + // ── fill 그라데이션 배선 (stroke는 단색 유지) ── + + const storedFillGradient = + effectiveColorState === 'active' + ? counterSettings.fillActiveGradient ?? null + : counterSettings.fillIdleGradient ?? null; + + const handleFillCommit = (value: ColorModeValue) => { + const pair = counterFillPair(value); + const key = effectiveColorState === 'active' ? 'fillActive' : 'fillIdle'; + setLocalColors((prev) => ({ ...prev, [key]: pair.color })); + if (effectiveColorState === 'active') { + handleCounterUpdate({ + fill: { ...counterSettings.fill, active: pair.color }, + fillActiveGradient: pair.gradient, + }); + } else { + handleCounterUpdate({ + fill: { ...counterSettings.fill, idle: pair.color }, + fillIdleGradient: pair.gradient, + }); + } + }; + + const fillGradientState = useGradientColorState({ + pair: + pickerFor === 'fill' + ? { + color: activeColorFor('fill', effectiveColorState), + gradient: storedFillGradient, + } + : {}, + fallbackColor: '#ffffff', + // 요소 종류·키 모드 포함 — 형식 왕복 기억이 다른 대상과 교차하지 않게 + contextKey: `${ + isStat ? 'stat' : 'key' + }:${selectedKeyType}:${keyIndex}:fill:${effectiveColorState}`, + canvasAnchor: + pickerFor === 'fill' + ? { kind: isStat ? 'stat' : 'key', index: keyIndex } + : undefined, + canvasSurface: 'counterFill', + canvasState: effectiveColorState, + onPreview: (value) => { + if (value.mode === 'solid') handleColorChange(value.color); + }, + onCommit: handleFillCommit, + }); + return ( <> @@ -276,7 +343,10 @@ const CounterTabContent: React.FC = ({ open={pickerFor === 'fill'} className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" - color={getDisplayColor(activeColorFor('fill', colorState))} + color={getDisplayColor(activeColorFor('fill', effectiveColorState))} + image={ + storedFillGradient ? gradientToCss(storedFillGradient) : undefined + } /> @@ -289,7 +359,9 @@ const CounterTabContent: React.FC = ({ open={pickerFor === 'stroke'} className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" - color={getDisplayColor(activeColorFor('stroke', colorState))} + color={getDisplayColor( + activeColorFor('stroke', effectiveColorState), + )} /> @@ -315,7 +387,7 @@ const CounterTabContent: React.FC = ({ {/* 폰트 크기 */} handleCounterUpdate({ fontSize: value })} suffix="px" min={8} @@ -388,15 +460,48 @@ const CounterTabContent: React.FC = ({ open={pickerOpen} referenceRef={pickerFor === 'fill' ? fillBtnRef : strokeBtnRef} panelElement={panelElement} - color={activeColorFor(pickerFor as 'fill' | 'stroke', colorState)} - onColorChange={(c: string) => handleColorChange(c)} - onColorChangeComplete={(c: string) => handleColorChangeComplete(c)} + color={ + pickerFor === 'fill' + ? fillGradientState.pickerColor + : activeColorFor( + pickerFor as 'fill' | 'stroke', + effectiveColorState, + ) + } + onColorChange={(c: string) => + pickerFor === 'fill' + ? fillGradientState.handlePickerColorChange(c, false) + : handleColorChange(c) + } + onColorChangeComplete={(c: string) => + pickerFor === 'fill' + ? fillGradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete(c) + } onClose={() => setPickerFor(null)} solidOnly={true} interactiveRefs={colorPickerInteractiveRefs} - stateMode={colorState} - onStateModeChange={(mode: string) => - setColorState(mode as ColorState) + stateMode={showActiveState ? effectiveColorState : undefined} + onStateModeChange={ + showActiveState + ? (mode: string) => setColorState(mode as ColorState) + : undefined + } + headerSlot={ + pickerFor === 'fill' ? fillGradientState.headerSlot : undefined + } + footerSlot={ + pickerFor === 'fill' ? fillGradientState.footerSlot : undefined + } + gradientSpec={ + pickerFor === 'fill' + ? fillGradientState.paletteGradientSpec + : undefined + } + onGradientSpecSelect={ + pickerFor === 'fill' + ? fillGradientState.handleGradientSpecSelect + : undefined } /> )} @@ -426,26 +531,8 @@ const CounterTabContent: React.FC = ({ animation={counterSettings.animation} counterSettings={counterSettings} keyVisual={{ - width: keyPosition.width, - height: keyPosition.height, - backgroundColor: keyPosition.backgroundColor, - borderColor: keyPosition.borderColor, - borderWidth: keyPosition.borderWidth, - borderRadius: keyPosition.borderRadius, - fontColor: keyPosition.fontColor, - fontSize: keyPosition.fontSize, - fontWeight: keyPosition.fontWeight, - fontFamily: keyPosition.fontFamily, - fontItalic: keyPosition.fontItalic, - fontUnderline: keyPosition.fontUnderline, - fontStrikethrough: keyPosition.fontStrikethrough, - displayText: keyPosition.displayText, + ...keyPosition, displayName: keyDisplayName, - className: keyPosition.className, - activeBackgroundColor: keyPosition.activeBackgroundColor, - activeBorderColor: keyPosition.activeBorderColor, - activeFontColor: keyPosition.activeFontColor, - useInlineStyles: keyPosition.useInlineStyles, isStat, }} onAnimationChange={handleAnimationUpdate} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 58fab310..6d3d56bf 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -12,7 +12,26 @@ import type { GraphItemType, } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import { + getActivePairPreservation, + gradientPairPatch, + gradientToCss, + type ColorModeValue, + type GradientSpec, +} from '@src/types/color'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; import { axisEventBus } from '@utils/core/axisEventBus'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_HAIRLINE, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { resolveElementShadow } from '@src/types/key/shadows'; import type { PluginSettingSchema, PluginMessages, @@ -43,6 +62,7 @@ import Dropdown from '@components/main/common/Dropdown'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import ShadowControls from '../ShadowControls'; const getStatTypeLabel = (statType?: StatItemType | null): string => { switch (statType) { @@ -535,8 +555,7 @@ export const SingleGraphPanel: React.FC = ({ > {}} onChangeComplete={(value) => @@ -545,6 +564,14 @@ export const SingleGraphPanel: React.FC = ({ backgroundColor: value, }) } + gradientValue={singleGraphPosition.backgroundGradient ?? null} + canvasAnchor={{ kind: 'graph', index: singleGraphIndex }} + onModeCommit={(_state, modeValue) => + handleGraphUpdate({ + index: singleGraphIndex, + ...gradientPairPatch('backgroundColor', modeValue), + }) + } colorId={`graph-bg-color-${selectedKeyType}-${singleGraphIndex}`} panelElement={panelElement} /> @@ -555,8 +582,7 @@ export const SingleGraphPanel: React.FC = ({ > {}} onChangeComplete={(value) => @@ -565,7 +591,16 @@ export const SingleGraphPanel: React.FC = ({ borderColor: value, }) } + gradientValue={singleGraphPosition.borderGradient ?? null} + canvasAnchor={{ kind: 'graph', index: singleGraphIndex }} + onModeCommit={(_state, modeValue) => + handleGraphUpdate({ + index: singleGraphIndex, + ...gradientPairPatch('borderColor', modeValue), + }) + } colorId={`graph-border-color-${selectedKeyType}-${singleGraphIndex}`} + gradientSurface="border" panelElement={panelElement} /> @@ -574,7 +609,7 @@ export const SingleGraphPanel: React.FC = ({ label={t('propertiesPanel.borderWidth') || 'Border Width'} > handleGraphUpdate({ index: singleGraphIndex, @@ -591,7 +626,9 @@ export const SingleGraphPanel: React.FC = ({ label={t('propertiesPanel.borderRadius') || 'Border Radius'} > handleGraphUpdate({ index: singleGraphIndex, @@ -665,6 +702,7 @@ export const SingleGraphPanel: React.FC = ({ open={showGraphImagePicker} referenceRef={graphImageButtonRef} panelElement={panelElement} + showActiveState={false} idleImage={singleGraphPosition.inactiveImage || ''} activeImage={singleGraphPosition.activeImage || ''} idleTransparent={false} @@ -825,10 +863,10 @@ export const SingleKnobPanel: React.FC = ({ : t('propertiesPanel.knobAxisUnset') || '미지정'; // 대기/입력 색상 (키 패널과 동일한 기본값/전환 로직) - const DEFAULT_KNOB_BACKGROUND_COLOR = 'rgba(46, 46, 47, 0.9)'; - const DEFAULT_KNOB_BORDER_COLOR = 'rgba(113, 113, 113, 0.9)'; - const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; + const DEFAULT_KNOB_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; + const DEFAULT_KNOB_BORDER_COLOR = DEFAULT_ELEMENT_FONT; + const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; + const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_FONT; type KnobColorTarget = 'backgroundColor' | 'borderColor'; type KnobColorProperty = @@ -912,30 +950,101 @@ export const SingleKnobPanel: React.FC = ({ setLocalColors((prev) => ({ ...prev, [prop]: color })); }; - const handleColorChangeComplete = ( - target: KnobColorTarget, - color: string, - ) => { - const prop = resolveColorProperty(target); - setLocalColors((prev) => ({ ...prev, [prop]: color })); + // ── 그라데이션 배선 (키 패널과 동일 패턴) — 단색 커밋도 이 경로로 통합 ── + + const storedGradientOf = (prop: KnobColorProperty): GradientSpec | null => { + switch (prop) { + case 'backgroundColor': + return singleKnobPosition.backgroundGradient ?? null; + case 'activeBackgroundColor': + return singleKnobPosition.activeBackgroundGradient ?? null; + case 'borderColor': + return singleKnobPosition.borderGradient ?? null; + case 'activeBorderColor': + return singleKnobPosition.activeBorderGradient ?? null; + default: + return null; + } + }; - const updates: Partial = { - [prop]: color, - } as Partial; + const gradientSpecFor = (target: KnobColorTarget): GradientSpec | null => { + const idleGradient = storedGradientOf(target); + if (colorState !== 'active') return idleGradient; + const activeProp = activeColorPropertyFor(target); + const activeGradient = storedGradientOf(activeProp); + const activeHasValue = + isNonEmptyString(singleKnobPosition[activeProp]) || + activeGradient != null; + return activeHasValue ? activeGradient : idleGradient; + }; - // idle 변경 시 active 값이 비어 있으면 현재 표시되던 active 값을 함께 저장 - // (active가 idle로 덮이는 현상 방지 — 키 패널과 동일) + const handleGradientCommit = (value: ColorModeValue) => { + if (!pickerFor) return; + const prop = resolveColorProperty(pickerFor); + const patch = gradientPairPatch( + prop as Parameters[0], + value, + ) as Partial; + + const baseColor = patch[prop]; + if (typeof baseColor === 'string') { + setLocalColors((prev) => ({ ...prev, [prop]: baseColor })); + } + + const updates: Partial = { ...patch }; + + // idle 편집 전 사용자 저장값 기준 active 쌍 보존 if (colorState !== 'active') { - const activeProp = activeColorPropertyFor(target); - const currentActive = singleKnobPosition[activeProp]; - if (!isNonEmptyString(currentActive)) { - updates[activeProp] = localColors[activeProp]; + const activeProp = activeColorPropertyFor(pickerFor); + const preservation = getActivePairPreservation( + { + color: singleKnobPosition[pickerFor], + gradient: storedGradientOf(pickerFor), + }, + { + color: singleKnobPosition[activeProp], + gradient: storedGradientOf(activeProp), + }, + ); + if (preservation?.color !== undefined) { + updates[activeProp] = preservation.color; + } + if (preservation?.gradient !== undefined) { + const activeSibling = + pickerFor === 'backgroundColor' + ? 'activeBackgroundGradient' + : 'activeBorderGradient'; + updates[activeSibling] = preservation.gradient; } } handleKnobUpdate({ index: singleKnobIndex, ...updates }); }; + const knobGradientState = useGradientColorState({ + pair: pickerFor + ? { + color: colorValueFor(pickerFor), + gradient: gradientSpecFor(pickerFor), + } + : {}, + fallbackColor: '#ffffff', + contextKey: `knob:${selectedKeyType}:${singleKnobIndex}:${ + pickerFor ?? 'none' + }:${colorState}`, + canvasAnchor: pickerFor + ? { kind: 'knob', index: singleKnobIndex } + : undefined, + canvasSurface: pickerFor === 'borderColor' ? 'border' : 'background', + canvasState: colorState, + onPreview: (value) => { + if (value.mode === 'solid' && pickerFor) { + handleColorChange(pickerFor, value.color); + } + }, + onCommit: handleGradientCommit, + }); + const handlePickerToggle = (target: KnobColorTarget) => { setPickerFor((prev) => (prev === target ? null : target)); }; @@ -949,6 +1058,34 @@ export const SingleKnobPanel: React.FC = ({ singleKnobPosition.height || 60, ) / 2, ); + const knobHasIdleImage = Boolean(singleKnobPosition.inactiveImage?.trim()); + const knobHasActiveImage = Boolean( + singleKnobPosition.activeImage?.trim() || + singleKnobPosition.inactiveImage?.trim(), + ); + const suppressKnobDefaultShadow = (singleKnobPosition.borderWidth ?? 0) > 0; + const knobIdleShadow = resolveElementShadow({ + active: false, + shadow: singleKnobPosition.shadow, + activeShadow: singleKnobPosition.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault: + knobHasIdleImage || + singleKnobPosition.idleTransparent === true || + suppressKnobDefaultShadow, + }); + const knobActiveShadow = resolveElementShadow({ + active: true, + shadow: singleKnobPosition.shadow, + activeShadow: singleKnobPosition.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault: + knobHasActiveImage || + singleKnobPosition.activeTransparent === true || + suppressKnobDefaultShadow, + }); return (
@@ -1124,6 +1261,10 @@ export const SingleKnobPanel: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={colorValueFor('backgroundColor')} + image={(() => { + const spec = gradientSpecFor('backgroundColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -1139,6 +1280,10 @@ export const SingleKnobPanel: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={colorValueFor('borderColor')} + image={(() => { + const spec = gradientSpecFor('borderColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -1147,7 +1292,7 @@ export const SingleKnobPanel: React.FC = ({ label={t('propertiesPanel.borderWidth') || '테두리 두께'} > handleKnobUpdate({ index: singleKnobIndex, @@ -1194,22 +1339,64 @@ export const SingleKnobPanel: React.FC = ({ {useCustomCSS && ( - - - handleKnobUpdate({ - index: singleKnobIndex, - className: classNameDraft || '', - }) - } - placeholder="className" - width="90px" - /> - + <> +
+

+ {t('propertiesPanel.useInlineStyles') || + '인라인 스타일 우선'} +

+ + handleKnobUpdate({ + index: singleKnobIndex, + useInlineStyles: !( + singleKnobPosition.useInlineStyles ?? false + ), + }) + } + /> +
+ + + + handleKnobUpdate({ + index: singleKnobIndex, + className: classNameDraft || '', + }) + } + placeholder="className" + width="90px" + /> + + )} + + + handleKnobUpdate({ + index: singleKnobIndex, + [state === 'active' ? 'activeShadow' : 'shadow']: shadow, + }) + } + onEnabledChange={(enabled) => + handleKnobUpdate({ + index: singleKnobIndex, + shadow: { ...knobIdleShadow, enabled }, + activeShadow: { ...knobActiveShadow, enabled }, + }) + } + panelElement={panelElement} + t={t} + />
@@ -1281,16 +1468,22 @@ export const SingleKnobPanel: React.FC = ({ pickerFor === 'backgroundColor' ? bgColorBtnRef : borderColorBtnRef } panelElement={panelElement} - color={colorValueFor(pickerFor)} - onColorChange={(c: string) => handleColorChange(pickerFor, c)} + color={knobGradientState.pickerColor} + onColorChange={(c: string) => + knobGradientState.handlePickerColorChange(c, false) + } onColorChangeComplete={(c: string) => - handleColorChangeComplete(pickerFor, c) + knobGradientState.handlePickerColorChange(c, true) } onClose={() => setPickerFor(null)} solidOnly={true} stateMode={colorState} onStateModeChange={setColorState} interactiveRefs={[bgColorBtnRef, borderColorBtnRef]} + headerSlot={knobGradientState.headerSlot} + footerSlot={knobGradientState.footerSlot} + gradientSpec={knobGradientState.paletteGradientSpec} + onGradientSpecSelect={knobGradientState.handleGradientSpecSelect} /> )}
@@ -1548,6 +1741,10 @@ export const SingleKeyStatPanel: React.FC = ({ onPositionChange={handleKeyLikePositionChange} onKeyUpdate={handleKeyLikeUpdate} onKeyPreview={handleKeyLikePreview} + canvasAnchor={{ + kind: isSingleStat ? 'stat' : 'key', + index: keyLikeIndex, + }} onKeyMappingChange={isSingleStat ? undefined : onKeyMappingChange} isListening={isListening} onKeyListen={isSingleStat ? undefined : handleKeyListen} @@ -1558,6 +1755,7 @@ export const SingleKeyStatPanel: React.FC = ({ : undefined } showSoundControls={!isSingleStat} + shadowActiveState={!isSingleStat} showImagePicker={showImagePicker} onToggleImagePicker={() => setShowImagePicker(!showImagePicker)} imageButtonRef={imageButtonRef} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 99daa694..ab5117ff 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -11,12 +11,36 @@ import { FontStyleToggle, } from '../PropertyInputs'; import { usePanelNav } from '../PanelNavContext'; +import { useKeyStore } from '@stores/data/useKeyStore'; import ImagePicker from '../../../Modal/content/pickers/ImagePicker'; import ColorPicker from '../../../Modal/content/pickers/ColorPicker'; import FontPicker from '../../../Modal/content/pickers/FontPicker'; import SoundPicker from '../../../Modal/content/pickers/SoundPicker'; import Checkbox from '../../../common/Checkbox'; import { ColorSwatchButton } from '../../../Modal/content/pickers/ColorSwatch'; +import ShadowControls from '../ShadowControls'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { + getActivePairPreservation, + gradientPairPatch, + gradientToCss, + type ColorModeValue, + type GradientSpec, +} from '@src/types/color'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { resolveElementShadow } from '@src/types/key/shadows'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'single-style:font'; @@ -32,6 +56,7 @@ type PickerTarget = type ColorState = 'idle' | 'active'; type StyleColorTarget = 'backgroundColor' | 'borderColor' | 'fontColor'; +type GradientColorTarget = 'backgroundColor' | 'borderColor'; type ActiveStyleColorProperty = | 'activeBackgroundColor' | 'activeBorderColor' @@ -71,11 +96,13 @@ const StyleTabContent: React.FC = ({ mappingLabel, hideDisplayText = false, showSoundControls = true, + shadowActiveState = true, showImagePicker = false, onToggleImagePicker, imageButtonRef, panelElement, useCustomCSS = false, + canvasAnchor, t, // 로컬 상태 localDx, @@ -88,19 +115,48 @@ const StyleTabContent: React.FC = ({ onLocalHeightChange, onSizeBlur, }) => { - const DEFAULT_KEY_BACKGROUND_COLOR = 'rgba(46, 46, 47, 0.9)'; - const DEFAULT_KEY_BORDER_COLOR = 'rgba(113, 113, 113, 0.9)'; - const DEFAULT_KEY_FONT_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KEY_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KEY_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; - const DEFAULT_KEY_ACTIVE_FONT_COLOR = '#FFFFFF'; + const DEFAULT_KEY_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; + const DEFAULT_KEY_BORDER_COLOR = DEFAULT_ELEMENT_BORDER; + const DEFAULT_KEY_FONT_COLOR = DEFAULT_ELEMENT_FONT; + const DEFAULT_KEY_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; + const DEFAULT_KEY_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; + const DEFAULT_KEY_ACTIVE_FONT_COLOR = DEFAULT_ELEMENT_ACTIVE_FONT; // 개별 편집 모드인지 확인 (로컬 상태 핸들러가 없으면 개별 편집 모드) const isIndividualMode = !onLocalDxChange; + const hasIdleImage = Boolean(keyPosition.inactiveImage?.trim()); + const hasActiveImage = Boolean( + keyPosition.activeImage?.trim() || keyPosition.inactiveImage?.trim(), + ); + const idleShadow = resolveElementShadow({ + active: false, + shadow: keyPosition.shadow, + activeShadow: keyPosition.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault: hasIdleImage, + }); + const activeShadow = resolveElementShadow({ + active: true, + shadow: keyPosition.shadow, + activeShadow: keyPosition.activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault: hasActiveImage, + }); // 통합 피커 상태 const [pickerFor, setPickerFor] = useState(null); const [colorState, setColorState] = useState('idle'); + const effectiveColorState = shadowActiveState ? colorState : 'idle'; + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); + + useEffect(() => { + if (!shadowActiveState) { + setColorState('idle'); + setPickerFor(null); + } + }, [shadowActiveState]); // 컬러 버튼 refs const bgColorBtnRef = useRef(null); @@ -206,7 +262,7 @@ const StyleTabContent: React.FC = ({ const resolveColorProperty = ( target: StyleColorTarget, ): StyleColorProperty => { - if (colorState !== 'active') return target; + if (effectiveColorState !== 'active') return target; switch (target) { case 'backgroundColor': return 'activeBackgroundColor'; @@ -232,6 +288,22 @@ const StyleTabContent: React.FC = ({ } }; + // 상태별 저장된 gradient 형제 값 + const storedGradientOf = (prop: StyleColorProperty): GradientSpec | null => { + switch (prop) { + case 'backgroundColor': + return keyPosition.backgroundGradient ?? null; + case 'activeBackgroundColor': + return keyPosition.activeBackgroundGradient ?? null; + case 'borderColor': + return keyPosition.borderGradient ?? null; + case 'activeBorderColor': + return keyPosition.activeBorderGradient ?? null; + default: + return null; + } + }; + const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; @@ -258,19 +330,122 @@ const StyleTabContent: React.FC = ({ [prop]: color, } as Partial; - // "idle" 상태에서만 변경했을 때 active 값이 비어 있으면, - // 현재 표시되던 active 값을 함께 저장해(active가 idle로 덮이는 현상 방지) - if (colorState !== 'active') { + // idle 편집 전 사용자 저장값 기준 active 모습 보존 + if (shadowActiveState && effectiveColorState !== 'active') { const activeProp = activeColorPropertyFor(target); - const currentActive = keyPosition[activeProp]; - if (!isNonEmptyString(currentActive)) { - updates[activeProp] = localColors[activeProp]; + const preservation = getActivePairPreservation( + { + color: keyPosition[target], + gradient: storedGradientOf(target), + }, + { + color: keyPosition[activeProp], + gradient: storedGradientOf(activeProp), + }, + ); + if (preservation?.color !== undefined) { + updates[activeProp] = preservation.color; + } + if (target !== 'fontColor' && preservation?.gradient !== undefined) { + const activeSibling = + target === 'backgroundColor' + ? 'activeBackgroundGradient' + : 'activeBorderGradient'; + updates[activeSibling] = preservation.gradient; } } onKeyUpdate({ index: keyIndex, ...updates }); }; + // ── 그라데이션 배선 (배경·테두리 전용, 글꼴 색상은 단색 유지) ── + + const gradientTarget: GradientColorTarget | null = + pickerFor === 'backgroundColor' || pickerFor === 'borderColor' + ? pickerFor + : null; + + const gradientSpecFor = ( + target: GradientColorTarget, + ): GradientSpec | null => { + const idleGradient = storedGradientOf(target); + if (effectiveColorState !== 'active') return idleGradient; + const activeProp = activeColorPropertyFor(target); + const activeGradient = storedGradientOf(activeProp); + const activeHasValue = + isNonEmptyString(keyPosition[activeProp]) || activeGradient != null; + return activeHasValue ? activeGradient : idleGradient; + }; + + const handleGradientPreview = (value: ColorModeValue) => { + if (!gradientTarget) return; + if (value.mode === 'solid') handleColorChange(gradientTarget, value.color); + }; + + const handleGradientCommit = (value: ColorModeValue) => { + if (!gradientTarget) return; + const prop = resolveColorProperty(gradientTarget); + const patch = gradientPairPatch( + prop as Parameters[0], + value, + ) as Partial; + + const baseColor = patch[prop]; + if (typeof baseColor === 'string') { + setLocalColors((prev) => ({ ...prev, [prop]: baseColor })); + } + + const updates: Partial = { ...patch }; + + // idle 편집 전 사용자 저장값 기준 active 쌍 보존 + if (shadowActiveState && effectiveColorState !== 'active') { + const activeProp = activeColorPropertyFor(gradientTarget); + const preservation = getActivePairPreservation( + { + color: keyPosition[gradientTarget], + gradient: storedGradientOf(gradientTarget), + }, + { + color: keyPosition[activeProp], + gradient: storedGradientOf(activeProp), + }, + ); + if (preservation?.color !== undefined) { + updates[activeProp] = preservation.color; + } + if (preservation?.gradient !== undefined) { + const activeSibling = + gradientTarget === 'backgroundColor' + ? 'activeBackgroundGradient' + : 'activeBorderGradient'; + updates[activeSibling] = preservation.gradient; + } + } + + onKeyUpdate({ index: keyIndex, ...updates }); + }; + + const gradientState = useGradientColorState({ + pair: gradientTarget + ? { + color: colorValueFor(gradientTarget), + gradient: gradientSpecFor(gradientTarget), + } + : {}, + fallbackColor: '#ffffff', + // 요소 종류·키 모드 포함 — 형식 왕복 기억이 다른 대상과 교차하지 않게 + contextKey: `${ + canvasAnchor?.kind ?? 'key' + }:${selectedKeyType}:${keyIndex}:${ + pickerFor ?? 'none' + }:${effectiveColorState}`, + canvasAnchor: gradientTarget ? canvasAnchor : undefined, + canvasSurface: gradientTarget === 'borderColor' ? 'border' : 'background', + canvasState: effectiveColorState, + onPreview: handleGradientPreview, + onCommit: handleGradientCommit, + }); + // 위치 변경 핸들러 const handlePositionXChange = (value: number) => { if (onLocalDxChange) { @@ -499,6 +674,10 @@ const StyleTabContent: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={getDisplayColor(colorValueFor('backgroundColor'))} + image={(() => { + const spec = gradientSpecFor('backgroundColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -512,13 +691,17 @@ const StyleTabContent: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={getDisplayColor(colorValueFor('borderColor'))} + image={(() => { + const spec = gradientSpecFor('borderColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> {/* 테두리 두께 */} handleStyleChangeComplete('borderWidth', value) } @@ -533,7 +716,7 @@ const StyleTabContent: React.FC = ({ {/* 모서리 반경 */} handleStyleChangeComplete('borderRadius', value) } @@ -564,6 +747,30 @@ const StyleTabContent: React.FC = ({ )} + + onKeyUpdate({ + index: keyIndex, + [state === 'active' ? 'activeShadow' : 'shadow']: shadow, + }) + } + onEnabledChange={(enabled) => + onKeyUpdate({ + index: keyIndex, + shadow: { ...idleShadow, enabled }, + // 눌림 상태가 없는 요소는 activeShadow를 기록하지 않음 + ...(shadowActiveState + ? { activeShadow: { ...activeShadow, enabled } } + : {}), + }) + } + panelElement={panelElement} + t={t} + /> + {/* 텍스트·폰트 */} {/* 표시 텍스트 */} @@ -627,7 +834,9 @@ const StyleTabContent: React.FC = ({ {/* 글꼴 스타일 */} = 700} + isBold={ + (keyPosition.fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT) >= 700 + } isItalic={keyPosition.fontItalic ?? false} isUnderline={keyPosition.fontUnderline ?? false} isStrikethrough={keyPosition.fontStrikethrough ?? false} @@ -754,6 +963,7 @@ const StyleTabContent: React.FC = ({ onIdleImageReset={handleIdleImageReset} onActiveImageReset={handleActiveImageReset} onClose={() => onToggleImagePicker()} + showActiveState={shadowActiveState} /> )} @@ -769,18 +979,34 @@ const StyleTabContent: React.FC = ({ : fontColorBtnRef } panelElement={panelElement} - color={colorValueFor(pickerFor as StyleColorTarget)} + color={ + gradientTarget + ? gradientState.pickerColor + : colorValueFor(pickerFor as StyleColorTarget) + } onColorChange={(c: string) => - handleColorChange(pickerFor as StyleColorTarget, c) + gradientTarget + ? gradientState.handlePickerColorChange(c, false) + : handleColorChange(pickerFor as StyleColorTarget, c) } onColorChangeComplete={(c: string) => - handleColorChangeComplete(pickerFor as StyleColorTarget, c) + gradientTarget + ? gradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete(pickerFor as StyleColorTarget, c) } onClose={() => setPickerFor(null)} solidOnly={true} - stateMode={colorState} - onStateModeChange={setColorState} + stateMode={shadowActiveState ? effectiveColorState : undefined} + onStateModeChange={shadowActiveState ? setColorState : undefined} interactiveRefs={colorPickerInteractiveRefs} + headerSlot={gradientTarget ? gradientState.headerSlot : undefined} + footerSlot={gradientTarget ? gradientState.footerSlot : undefined} + gradientSpec={ + gradientTarget ? gradientState.paletteGradientSpec : undefined + } + onGradientSpecSelect={ + gradientTarget ? gradientState.handleGradientSpecSelect : undefined + } /> )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts index 78f9a62f..79c5517d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts @@ -1,4 +1,9 @@ import type { KeyPosition } from '@src/types/key/keys'; +import type { ColorModeValue, GradientSpec } from '@src/types/color'; +import type { + GradientCanvasAnchor, + GradientPreviewSurface, +} from '@stores/grid/useGradientEditStore'; // ============================================================================ // 탭 상수 @@ -85,6 +90,15 @@ export interface ColorInputProps { // 외부에서 열림 상태를 제어할 때 사용 isOpen?: boolean; onToggle?: () => void; + /** gradient 편집 지원 — 저장된 상태별 스펙. onModeCommit이 있으면 활성화 */ + gradientValue?: GradientSpec | null; + activeGradientValue?: GradientSpec | null; + /** gradient 지원 커밋 경로 — 단색/그라데이션 확정을 한 콜백으로 수신 */ + onModeCommit?: (state: 'idle' | 'active', value: ColorModeValue) => void; + /** 온캔버스 각도 핸들 앵커 */ + canvasAnchor?: GradientCanvasAnchor; + /** 편집 표면 — 캔버스 일시 페인트 대상 필드 (기본 background) */ + gradientSurface?: GradientPreviewSurface; } export interface ToggleSwitchProps { @@ -167,6 +181,8 @@ export interface SingleKeyContentProps { // ============================================================================ export interface StyleTabContentProps { + /** 온캔버스 각도 핸들 앵커 (단일 키/통계) */ + canvasAnchor?: GradientCanvasAnchor; keyIndex: number; keyPosition: KeyPosition; keyCode: string | null; @@ -185,6 +201,8 @@ export interface StyleTabContentProps { // 표시 텍스트 입력 숨김 (통계 요소는 statType이 displayText 역할) hideDisplayText?: boolean; showSoundControls?: boolean; + // 눌림 상태가 없는 요소(통계)는 상태별 편집 표면에서 대기만 편집 + shadowActiveState?: boolean; showImagePicker?: boolean; onToggleImagePicker?: () => void; imageButtonRef?: React.RefObject; diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index d50b1057..aeca9700 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -26,6 +26,8 @@ import GridBackground from './GridBackground'; import SmartGuidesOverlay from '../overlays/SmartGuidesOverlay'; import MarqueeSelectionOverlay from '../overlays/MarqueeSelectionOverlay'; import ResizeHandles from '../handles/ResizeHandles'; +import GradientAxisOverlay from '../handles/GradientAxisHandle'; +import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import { isElementResizable } from '../handles/groupResizeUtils'; import KeyCounterPreviewLayer from '../layers/KeyCounterPreviewLayer'; @@ -65,6 +67,20 @@ import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { SaveData } from '@hooks/Modal/useUnifiedKeySettingState'; import { resolveImageSource } from '@utils/core/imageSource'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_HAIRLINE, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_SHADOW_SPEC, + DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, +} from '@utils/core/elementDefaults'; +import { + elementShadowToCss, + resolveElementShadow, +} from '@src/types/key/shadows'; import { groupSelectedElements, ungroupSelectedElements, @@ -272,6 +288,10 @@ const Grid = ({ }); // 선택 상태 관리 + // 온캔버스 그라데이션 편집 중 여부 — 리사이즈 핸들을 잠시 숨김 + const hasGradientEditSession = useGradientEditStore( + (state) => state.session !== null, + ); const selectedElements = useGridSelectionStore( (state) => state.selectedElements, ); @@ -1354,9 +1374,9 @@ const Grid = ({ width: `${width}px`, height: `${height}px`, transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`, - background: 'rgba(17, 17, 20, 0.9)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: '8px', + background: DEFAULT_ELEMENT_BG, + border: `1px solid ${DEFAULT_ELEMENT_HAIRLINE}`, + borderRadius: `${DEFAULT_ELEMENT_RADIUS}px`, opacity: 0.5, zIndex: 1000, }} @@ -1371,6 +1391,8 @@ const Grid = ({ inactiveImage, activeImage, className, + shadow, + activeShadow, }, keyName, } = duplicateState; @@ -1378,10 +1400,17 @@ const Grid = ({ resolveImageSource(inactiveImage) || resolveImageSource(activeImage) || ''; - const backgroundColor = previewImage - ? 'transparent' - : 'rgba(46, 46, 47, 0.9)'; - const borderStyle = '3px solid rgba(113, 113, 113, 0.9)'; + const backgroundColor = previewImage ? 'transparent' : DEFAULT_ELEMENT_BG; + const previewShadow = elementShadowToCss( + resolveElementShadow({ + active: false, + shadow, + activeShadow, + defaultShadow: DEFAULT_ELEMENT_SHADOW_SPEC, + defaultActiveShadow: DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, + suppressDefault: Boolean(previewImage), + }), + ); const displayName = getKeyInfoByGlobalKey(keyName)?.displayName || keyName || ''; @@ -1399,8 +1428,9 @@ const Grid = ({ height: `${height}px`, transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`, backgroundColor, - borderRadius: '10px', - border: borderStyle, + borderRadius: `${DEFAULT_ELEMENT_RADIUS}px`, + border: `${DEFAULT_ELEMENT_BORDER_WIDTH}px solid ${DEFAULT_ELEMENT_BORDER}`, + boxShadow: previewShadow, overflow: 'hidden', opacity: 0.5, zIndex: 1000, @@ -1424,7 +1454,7 @@ const Grid = ({
)} - + {renderDuplicateGhost()} {/* 선택된 요소 표시 - 그룹 리사이즈 중에는 개별 테두리 숨김 (흔들림 방지) */} {selectedElements.map((el, _idx) => { + // 온캔버스 그라데이션 편집 중에는 선택 테두리 숨김 (축·스톱만 표시) + if (hasGradientEditSession) return null; // 그룹 리사이즈 중에는 개별 요소 테두리 숨김 (스냅으로 인한 흔들림 방지) if (selectedElements.length > 1 && previewElementBounds) { return null; @@ -1804,6 +1840,8 @@ const Grid = ({ if (!bounds || !elementId) return null; + if (hasGradientEditSession) return null; + return ( 1 && ( + {selectedElements.length > 1 && !hasGradientEditSession && ( )} + {/* 온캔버스 그라데이션 각도 핸들 — 피커가 그라데이션 형식일 때만 표시 */} + {/* 우클릭 리스트 팝업 */}
; + statPositions: Record; + graphPositions?: Record; + knobPositions?: Record; + selectedElements: SelectedElement[]; + selectedKeyType: string; + zoom: number; + panX: number; + panY: number; +} + +// 축 끝 회전 앵커 — 시각 점과 히트 영역(px) +const ANCHOR_DOT_SIZE = 7; +const ANCHOR_HIT_SIZE = 18; +// 스톱 표식 — 앵커 점은 선 위, 색 스왓치는 그 바로 위에 붙는 태그 +const STOP_ANCHOR_DOT_SIZE = 5; +const SWATCH_SIZE = 15; +const SWATCH_LIFT = 16; +// 축 선의 드래그 히트 두께(px) — 시각 선은 1.5px, 잡는 영역은 넓게 +const AXIS_HIT_THICKNESS = 12; +// 자석 스냅 판정 각도(도) — 모서리·변 중앙 방향에 이 범위 안이면 흡착 +const MAGNET_THRESHOLD_DEG = 6; +// 클릭이 드래그로 승격되는 이동 임계값(px) +const DRAG_THRESHOLD_PX = 3; + +type AxisEnd = 'start' | 'end'; + +type DragState = + | { + type: 'rotate'; + pointerId: number; + end: AxisEnd; + ownerGeneration: number; + startSpec: GradientSpec; + moved: boolean; + /** 이동 없이 떼면 그 자리에 스톱 추가 — 축 히트 스트립 한정 */ + addOnClick: boolean; + downX: number; + downY: number; + } + | { + type: 'stop'; + pointerId: number; + index: number; + moved: boolean; + ownerGeneration: number; + startSpec: GradientSpec; + lastPos: number; + downX: number; + downY: number; + }; + +const normalizeAngle = (deg: number): number => ((deg % 360) + 360) % 360; + +const circularDistance = (a: number, b: number): number => { + const diff = Math.abs(normalizeAngle(a) - normalizeAngle(b)); + return Math.min(diff, 360 - diff); +}; + +// 변 중앙 4방향 + 모서리 4방향 (요소 종횡비 반영) +const buildMagnetAngles = (width: number, height: number): number[] => { + const corner = normalizeAngle( + (Math.atan2(width / 2, height / 2) * 180) / Math.PI, + ); + return [ + 0, + 90, + 180, + 270, + corner, + normalizeAngle(180 - corner), + normalizeAngle(180 + corner), + normalizeAngle(360 - corner), + ]; +}; + +const stopAll = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); +}; + +const GradientAxisOverlay = ({ + positions, + statPositions, + graphPositions, + knobPositions, + selectedElements, + selectedKeyType, + zoom, + panX, + panY, +}: GradientAxisOverlayProps) => { + const { t } = useTranslation(); + const session = useGradientEditStore((state) => state.session); + const dragRef = useRef(null); + const [dragAngle, setDragAngle] = useState(null); + const [dragStop, setDragStop] = useState<{ + index: number; + pos: number; + } | null>(null); + const rootRef = useRef(null); + // window 드래그 핸들러가 리렌더와 무관하게 최신 값을 읽도록 ref로 노출 + const sessionRef = useRef(session); + // eslint-disable-next-line react-hooks/refs + sessionRef.current = session; + const geoRef = useRef<{ + cx: number; + cy: number; + halfLine: number; + dirX: number; + dirY: number; + magnetAngles: number[]; + worldW: number; + worldH: number; + zoom: number; + } | null>(null); + // 드래그 세션 해제자 — begin에서 등록, 종료 경로 어디서든 1회 실행 + const detachRef = useRef<(() => void) | null>(null); + + // 드래그 중 언마운트(피커 닫힘 등)에도 window 리스너 정리 + useEffect(() => () => detachRef.current?.(), []); + + if (!session) return null; + + // 앵커 → 월드 bounds 해석 + const resolveBounds = (): Bounds | null => { + const { anchor } = session; + if (anchor.kind === 'batch') { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const el of selectedElements) { + if (el.index === undefined) continue; + if ( + session.stateMode === 'active' && + !supportsActiveVisualState(el.type) + ) { + continue; + } + const pos = + el.type === 'key' + ? positions[selectedKeyType]?.[el.index] + : el.type === 'stat' + ? statPositions[selectedKeyType]?.[el.index] + : el.type === 'graph' + ? graphPositions?.[selectedKeyType]?.[el.index] + : el.type === 'knob' + ? knobPositions?.[selectedKeyType]?.[el.index] + : undefined; + if (!pos) continue; + const w = pos.width || (el.type === 'graph' ? 200 : 60); + const h = pos.height || (el.type === 'graph' ? 100 : 60); + minX = Math.min(minX, pos.dx); + minY = Math.min(minY, pos.dy); + maxX = Math.max(maxX, pos.dx + w); + maxY = Math.max(maxY, pos.dy + h); + } + if (!Number.isFinite(minX)) return null; + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + const pos = + anchor.kind === 'key' + ? positions[selectedKeyType]?.[anchor.index] + : anchor.kind === 'stat' + ? statPositions[selectedKeyType]?.[anchor.index] + : anchor.kind === 'graph' + ? graphPositions?.[selectedKeyType]?.[anchor.index] + : knobPositions?.[selectedKeyType]?.[anchor.index]; + if (!pos) return null; + return { + x: pos.dx, + y: pos.dy, + width: pos.width || (anchor.kind === 'graph' ? 200 : 60), + height: pos.height || (anchor.kind === 'graph' ? 100 : 60), + }; + }; + + const bounds = resolveBounds(); + if (!bounds) return null; + + const cx = (bounds.x + bounds.width / 2) * zoom + panX; + const cy = (bounds.y + bounds.height / 2) * zoom + panY; + const magnetAngles = buildMagnetAngles(bounds.width, bounds.height); + + const angle = dragAngle ?? session.spec.angle; + const rad = (angle * Math.PI) / 180; + // CSS linear-gradient: 0deg = 위, 시계 방향 — 화면 좌표(y 아래)로 변환 + const dirX = Math.sin(rad); + const dirY = -Math.cos(rad); + // CSS 그라데이션 라인 절반 길이 — pos 0/1이 이 지점에 해당 + const halfLine = + ((Math.abs(bounds.width * Math.sin(rad)) + + Math.abs(bounds.height * Math.cos(rad))) / + 2) * + zoom; + const endX = cx + dirX * halfLine; + const endY = cy + dirY * halfLine; + // pos(0~1) → 축 위 화면 좌표 + const stopPoint = (pos: number) => ({ + x: cx + dirX * (pos - 0.5) * 2 * halfLine, + y: cy + dirY * (pos - 0.5) * 2 * halfLine, + }); + + // eslint-disable-next-line react-hooks/refs + geoRef.current = { + cx, + cy, + halfLine, + dirX, + dirY, + magnetAngles, + worldW: bounds.width, + worldH: bounds.height, + zoom, + }; + + // 화면(client) 기준 중심 — 드래그 중 휠 팬·줌으로 좌표가 움직여도 + // 매 이벤트에서 최신 지오메트리로 재계산한다 + const clientOrigin = () => { + const hostRect = rootRef.current?.getBoundingClientRect(); + const geo = geoRef.current; + return { + x: (hostRect?.left ?? 0) + (geo?.cx ?? 0), + y: (hostRect?.top ?? 0) + (geo?.cy ?? 0), + }; + }; + + const angleFromClient = ( + clientX: number, + clientY: number, + end: AxisEnd, + magnetDisabled: boolean, + ): number => { + const origin = clientOrigin(); + const raw = + (Math.atan2(clientX - origin.x, origin.y - clientY) * 180) / Math.PI; + // 시작점 쪽을 잡으면 축 반대 방향이 그라데이션 진행 방향 + let next = normalizeAngle(Math.round(end === 'start' ? raw + 180 : raw)); + if (!magnetDisabled) { + for (const magnet of geoRef.current?.magnetAngles ?? []) { + if (circularDistance(next, magnet) <= MAGNET_THRESHOLD_DEG) { + next = magnet; + break; + } + } + } + return normalizeAngle(Math.round(next)); + }; + + // 포인터를 축에 사영해 pos(0~1) 계산 + const posFromClient = (clientX: number, clientY: number): number => { + const origin = clientOrigin(); + const geo = geoRef.current; + if (!geo || geo.halfLine === 0) return 0.5; + const dx = clientX - origin.x; + const dy = clientY - origin.y; + const projected = + (dx * geo.dirX + dy * geo.dirY) / (2 * geo.halfLine) + 0.5; + return Math.min(1, Math.max(0, projected)); + }; + + const currentSpec = () => sessionRef.current?.spec ?? session.spec; + + const stopsWithMovedIndex = (index: number, pos: number) => + currentSpec().stops.map((s, i) => (i === index ? { ...s, pos } : s)); + + const commitStopDrag = (index: number, pos: number) => { + const live = sessionRef.current; + if (!live) return; + const stops = stopsWithMovedIndex(index, pos); + const next = toCanonicalGradient({ ...currentSpec(), stops }); + // canonical과 동일한 안정 정렬을 원본 인덱스 태그로 재현해 선택 재매핑 + const sortedIndexes = stops + .map((s, i) => ({ i, pos: Math.min(1, Math.max(0, s.pos)) })) + .sort((a, b) => a.pos - b.pos); + const newIndex = sortedIndexes.findIndex((s) => s.i === index); + if (newIndex >= 0) live.selectStop(newIndex); + live.apply(next, true); + }; + + // 축 클릭 스톱 추가 — 색은 선택 스톱 기준 (피커 스톱 바와 동일 규칙) + const addStopAt = (pos: number) => { + const live = sessionRef.current; + if (!live) return; + const spec = currentSpec(); + if (spec.stops.length >= GRADIENT_STOPS_MAX) return; + const color = + spec.stops[live.selectedIndex]?.color ?? + spec.stops[0]?.color ?? + '#ffffff'; + const next = toCanonicalGradient({ + ...spec, + stops: [...spec.stops, { color, pos }], + }); + const newIndex = next.stops.findIndex( + (s) => s.pos === pos && s.color === color, + ); + live.selectStop(newIndex >= 0 ? newIndex : next.stops.length - 1); + live.apply(next, true); + }; + + // 드래그 소유 세션 검증 — 세션이 사라지거나 한 번이라도 교체되면 중단. + // 세대 비교라 포인터 이벤트 사이의 A→B→새 A 왕복도 잡는다 + const ownedDrag = (e: PointerEvent): DragState | null => { + const drag = dragRef.current; + if (!drag || e.pointerId !== drag.pointerId) return null; + const live = sessionRef.current; + if ( + !live || + drag.ownerGeneration !== useGradientEditStore.getState().generation + ) { + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + return null; + } + return drag; + }; + + const handleWindowMove = (e: PointerEvent) => { + const drag = ownedDrag(e); + if (!drag) return; + // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 — 커밋 없이 종료 + if (e.buttons === 0) { + cancelActiveDrag(); + return; + } + const live = sessionRef.current; + if (!live) return; + if (!drag.moved) { + if ( + Math.hypot(e.clientX - drag.downX, e.clientY - drag.downY) < + DRAG_THRESHOLD_PX + ) { + return; + } + drag.moved = true; + } + + if (drag.type === 'rotate') { + const next = angleFromClient( + e.clientX, + e.clientY, + drag.end, + e.ctrlKey || e.metaKey, + ); + setDragAngle(next); + live.apply({ ...currentSpec(), angle: next }, false); + return; + } + + // 스톱은 항상 현재 축에 사영 — 위치만 이동, 각도 불변 + const pos = posFromClient(e.clientX, e.clientY); + drag.lastPos = pos; + setDragStop({ index: drag.index, pos }); + live.apply( + { ...currentSpec(), stops: stopsWithMovedIndex(drag.index, pos) }, + false, + ); + }; + + const handleWindowUp = (e: PointerEvent) => { + const drag = ownedDrag(e); + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + if (!live) return; + if (drag.type === 'rotate') { + if (!drag.moved) { + // 클릭 — 축 히트 스트립이면 그 위치에 스톱 추가 + if (drag.addOnClick) addStopAt(posFromClient(e.clientX, e.clientY)); + return; + } + const finalAngle = angleFromClient( + e.clientX, + e.clientY, + drag.end, + e.ctrlKey || e.metaKey, + ); + live.apply( + toCanonicalGradient({ ...currentSpec(), angle: finalAngle }), + true, + ); + return; + } + if (!drag.moved) return; // 클릭 — 선택만 + commitStopDrag(drag.index, drag.lastPos); + }; + + // 취소 — preview로 반영된 변경을 시작 시점 spec으로 복원 + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + // 세대가 드래그 시작 시점 그대로일 때만 복원 — 포인터 이벤트 없이 + // A→B→새 A로 교체된 세션에 stale 롤백이 새지 않게 + if ( + live && + drag.ownerGeneration === useGradientEditStore.getState().generation + ) { + live.apply(drag.startSpec, false); + } + }; + + const handleWindowCancel = (e: PointerEvent) => { + const drag = dragRef.current; + if (!drag || e.pointerId !== drag.pointerId) return; + cancelActiveDrag(); + }; + + // 창 포커스 상실 — pointerup이 오지 않으므로 유령 드래그 방지 취소 + const handleWindowBlur = () => cancelActiveDrag(); + + // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 + const suppressNextClick = () => { + const swallow = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + // click이 아예 안 오는 경로(cancel 등) 대비 — 다음 틱에 정리 + setTimeout(() => window.removeEventListener('click', swallow, true), 0); + }; + + const attachWindowDrag = () => { + window.addEventListener('pointermove', handleWindowMove); + window.addEventListener('pointerup', handleWindowUp); + window.addEventListener('pointercancel', handleWindowCancel); + window.addEventListener('blur', handleWindowBlur); + detachRef.current = () => { + window.removeEventListener('pointermove', handleWindowMove); + window.removeEventListener('pointerup', handleWindowUp); + window.removeEventListener('pointercancel', handleWindowCancel); + window.removeEventListener('blur', handleWindowBlur); + detachRef.current = null; + dragRef.current = null; + suppressNextClick(); + }; + }; + + const beginRotateDrag = ( + e: React.PointerEvent, + end: AxisEnd, + addOnClick: boolean, + ) => { + if (e.button !== 0) return; + stopAll(e); + detachRef.current?.(); + dragRef.current = { + type: 'rotate', + pointerId: e.pointerId, + end, + ownerGeneration: useGradientEditStore.getState().generation, + startSpec: session.spec, + moved: false, + addOnClick, + downX: e.clientX, + downY: e.clientY, + }; + attachWindowDrag(); + }; + + // 축 히트 스트립 — 잡은 지점이 축의 어느 절반인지로 회전 기준 방향 결정 + const beginStripPointer = (e: React.PointerEvent) => { + // preventDefault가 기본 포커스 이동을 막으므로 명시 부여 — 화살표 각도 조절이 + // 그리드 키 이동으로 새지 않게 슬라이더가 포커스를 가져간다 + if (e.button === 0) { + e.currentTarget.focus({ preventScroll: true }); + } + const origin = clientOrigin(); + const geo = geoRef.current; + const along = + (e.clientX - origin.x) * (geo?.dirX ?? 0) + + (e.clientY - origin.y) * (geo?.dirY ?? 0); + beginRotateDrag(e, along >= 0 ? 'end' : 'start', true); + }; + + const beginAnchorRotate = + (end: AxisEnd) => (e: React.PointerEvent) => + beginRotateDrag(e, end, false); + + const beginStopDrag = + (index: number) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + stopAll(e); + detachRef.current?.(); + session.selectStop(index); + dragRef.current = { + type: 'stop', + pointerId: e.pointerId, + index, + moved: false, + ownerGeneration: useGradientEditStore.getState().generation, + startSpec: session.spec, + lastPos: session.spec.stops[index]?.pos ?? 0.5, + downX: e.clientX, + downY: e.clientY, + }; + attachWindowDrag(); + }; + + // 키보드 각도 조절 — 화살표 ±1°, Shift ±15° (슬라이더 시맨틱) + const handleRotateKeyDown = (e: React.KeyboardEvent) => { + let delta = 0; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = 1; + else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -1; + else return; + e.preventDefault(); + e.stopPropagation(); + const step = e.shiftKey ? 15 : 1; + const next = normalizeAngle(session.spec.angle + delta * step); + session.apply(toCanonicalGradient({ ...session.spec, angle: next }), true); + }; + + // 우클릭 삭제 — 최소 2개 유지 (피커 스톱 바와 동일 규칙) + const handleStopContextMenu = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (session.spec.stops.length <= 2) return; + const stops = session.spec.stops.filter((_, i) => i !== index); + const nextSelected = + session.selectedIndex > index + ? session.selectedIndex - 1 + : Math.min(session.selectedIndex, stops.length - 1); + session.selectStop(Math.max(0, nextSelected)); + session.apply(toCanonicalGradient({ ...session.spec, stops }), true); + }; + + const isRotating = dragAngle !== null; + const dragStopScreen = dragStop ? stopPoint(dragStop.pos) : null; + + return ( +
+ {/* 축 선 (시각) */} +
+ {/* 축 히트 스트립 — 드래그 = 회전, 클릭 = 스톱 추가, 화살표 = 미세 조절 */} +
+ {/* 축 끝 앵커 — 선 끝점 위 흰 점, 드래그로 각도만 조절 */} + {(['start', 'end'] as AxisEnd[]).map((end) => { + const point = stopPoint(end === 'end' ? 1 : 0); + return ( + + ); + })} + {/* 스톱 — 앵커 점은 선 위, 색 스왓치는 바로 위 태그. 드래그 = 위치, 우클릭 = 삭제 */} + {session.spec.stops.map((stop, i) => { + const point = stopPoint(stop.pos); + const isAxisEnd = stop.pos === 0 || stop.pos === 1; + return ( + + {!isAxisEnd && ( +